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' => '
| |  | 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 !!}