From 508d5186db67b34914e2d60cb84ff4442e449661 Mon Sep 17 00:00:00 2001 From: pbao9 Date: Thu, 12 Mar 2026 12:31:52 +0700 Subject: [PATCH] feat: Reorganize database migrations for module snapshots, VMware, and OS groups, and implement customer order creation email notifications. --- .../Resources/Api/Admin/OrderResource.php | 24 +++++- app/Jobs/Mail/Customer/OrderCreatedJob.php | 39 +++++++++ .../Create/CreateVirtualMachineJob.php | 17 ++++ app/Mail/Customer/OrderCreated.php | 64 ++++++++++++++ app/Models/Order.php | 6 ++ app/Models/OrderItem.php | 10 +++ app/Repositories/Order/OrderRepository.php | 2 + app/Services/OrderService.php | 37 +++++++-- ...oup.php => 2024_02_02_194120_os_group.php} | 0 .../2024_02_03_095630_module_vmware.php | 59 ------------- .../2024_02_04_100435_module_snapshot.php | 34 -------- ...26_01_17_143127_update_column_snapshot.php | 49 ----------- ...7_151930_update_column_module_snapshot.php | 39 --------- ...026_03_11_200003_create_invoices_table.php | 1 - .../2026_03_12_095630_module_vmware.php | 83 +++++++++++++++++++ .../2026_03_12_100435_module_snapshot.php | 71 ++++++++++++++++ database/seeders/DatabaseSeeder.php | 2 +- database/seeders/TemplateSeeder.php | 7 ++ resources/views/emails/template.blade.php | 1 + 19 files changed, 350 insertions(+), 195 deletions(-) create mode 100644 app/Jobs/Mail/Customer/OrderCreatedJob.php create mode 100644 app/Mail/Customer/OrderCreated.php rename database/migrations/{2024_02_02_194120_os__group.php => 2024_02_02_194120_os_group.php} (100%) delete mode 100644 database/migrations/2024_02_03_095630_module_vmware.php delete mode 100644 database/migrations/2024_02_04_100435_module_snapshot.php delete mode 100644 database/migrations/2026_01_17_143127_update_column_snapshot.php delete mode 100644 database/migrations/2026_01_17_151930_update_column_module_snapshot.php create mode 100644 database/migrations/2026_03_12_095630_module_vmware.php create mode 100644 database/migrations/2026_03_12_100435_module_snapshot.php create mode 100644 resources/views/emails/template.blade.php diff --git a/app/Http/Resources/Api/Admin/OrderResource.php b/app/Http/Resources/Api/Admin/OrderResource.php index 48aa0cd..fe128fe 100644 --- a/app/Http/Resources/Api/Admin/OrderResource.php +++ b/app/Http/Resources/Api/Admin/OrderResource.php @@ -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, ]; })), ]; diff --git a/app/Jobs/Mail/Customer/OrderCreatedJob.php b/app/Jobs/Mail/Customer/OrderCreatedJob.php new file mode 100644 index 0000000..92c5001 --- /dev/null +++ b/app/Jobs/Mail/Customer/OrderCreatedJob.php @@ -0,0 +1,39 @@ +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, + ]); + } + } +} diff --git a/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php b/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php index d90829d..b35a153 100644 --- a/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php +++ b/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php @@ -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, diff --git a/app/Mail/Customer/OrderCreated.php b/app/Mail/Customer/OrderCreated.php new file mode 100644 index 0000000..97f07aa --- /dev/null +++ b/app/Mail/Customer/OrderCreated.php @@ -0,0 +1,64 @@ +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 []; + } +} diff --git a/app/Models/Order.php b/app/Models/Order.php index 51cde55..14c20c3 100644 --- a/app/Models/Order.php +++ b/app/Models/Order.php @@ -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'); + } } diff --git a/app/Models/OrderItem.php b/app/Models/OrderItem.php index 1e5c580..72f4b5c 100644 --- a/app/Models/OrderItem.php +++ b/app/Models/OrderItem.php @@ -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'); diff --git a/app/Repositories/Order/OrderRepository.php b/app/Repositories/Order/OrderRepository.php index b2d20fe..00782ac 100644 --- a/app/Repositories/Order/OrderRepository.php +++ b/app/Repositories/Order/OrderRepository.php @@ -139,6 +139,8 @@ class OrderRepository extends BaseRepository implements OrderRepositoryInterface return Order::with([ 'orderItems.product', 'orderItems.os', + 'orderItems.service', + 'moduleVmwares.ipAddress', 'user', 'customer', 'payment', diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index b967ec0..7893112 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -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 diff --git a/database/migrations/2024_02_02_194120_os__group.php b/database/migrations/2024_02_02_194120_os_group.php similarity index 100% rename from database/migrations/2024_02_02_194120_os__group.php rename to database/migrations/2024_02_02_194120_os_group.php diff --git a/database/migrations/2024_02_03_095630_module_vmware.php b/database/migrations/2024_02_03_095630_module_vmware.php deleted file mode 100644 index 7bbe6b3..0000000 --- a/database/migrations/2024_02_03_095630_module_vmware.php +++ /dev/null @@ -1,59 +0,0 @@ -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'); - } -}; diff --git a/database/migrations/2024_02_04_100435_module_snapshot.php b/database/migrations/2024_02_04_100435_module_snapshot.php deleted file mode 100644 index 4cd4cf5..0000000 --- a/database/migrations/2024_02_04_100435_module_snapshot.php +++ /dev/null @@ -1,34 +0,0 @@ -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'); - } -}; diff --git a/database/migrations/2026_01_17_143127_update_column_snapshot.php b/database/migrations/2026_01_17_143127_update_column_snapshot.php deleted file mode 100644 index c6aecfa..0000000 --- a/database/migrations/2026_01_17_143127_update_column_snapshot.php +++ /dev/null @@ -1,49 +0,0 @@ -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', - ]); - }); - } -}; diff --git a/database/migrations/2026_01_17_151930_update_column_module_snapshot.php b/database/migrations/2026_01_17_151930_update_column_module_snapshot.php deleted file mode 100644 index 2c0e44f..0000000 --- a/database/migrations/2026_01_17_151930_update_column_module_snapshot.php +++ /dev/null @@ -1,39 +0,0 @@ -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) { - // - }); - } -}; diff --git a/database/migrations/2026_03_11_200003_create_invoices_table.php b/database/migrations/2026_03_11_200003_create_invoices_table.php index ca99739..ca2f6ec 100644 --- a/database/migrations/2026_03_11_200003_create_invoices_table.php +++ b/database/migrations/2026_03_11_200003_create_invoices_table.php @@ -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(); diff --git a/database/migrations/2026_03_12_095630_module_vmware.php b/database/migrations/2026_03_12_095630_module_vmware.php new file mode 100644 index 0000000..b1f47e7 --- /dev/null +++ b/database/migrations/2026_03_12_095630_module_vmware.php @@ -0,0 +1,83 @@ +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'); + } +}; diff --git a/database/migrations/2026_03_12_100435_module_snapshot.php b/database/migrations/2026_03_12_100435_module_snapshot.php new file mode 100644 index 0000000..0f7885c --- /dev/null +++ b/database/migrations/2026_03_12_100435_module_snapshot.php @@ -0,0 +1,71 @@ +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'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index b0e5734..737e76d 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -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); } } diff --git a/database/seeders/TemplateSeeder.php b/database/seeders/TemplateSeeder.php index 12da236..15b7e6a 100644 --- a/database/seeders/TemplateSeeder.php +++ b/database/seeders/TemplateSeeder.php @@ -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' => '
{$company_name}

Kính chào {$client_name},

Cảm ơn bạn đã đặt hàng tại {$company_name}! Đơn hàng của bạn đã được tiếp nhận thành công.

Thông tin đơn hàng:

  • Mã đơn hàng: {$order_num}
  • Mã hóa đơn: {$invoice_num}
  • Tổng tiền: {$order_total} đ

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.

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ợ.

Lưu ý:

  • Đây là tin nhắn tự động. Vui lòng không trả lời tin nhắn này!

', + 'type_id' => 0, + 'status' => 1 + ], [ 'name' => 'Service Expiry', 'subject' => 'Thông báo dịch vụ sắp hết hạn', diff --git a/resources/views/emails/template.blade.php b/resources/views/emails/template.blade.php new file mode 100644 index 0000000..7c2eab4 --- /dev/null +++ b/resources/views/emails/template.blade.php @@ -0,0 +1 @@ +{!! $template !!}