From 0bd15f60187ec973af1ce610aa52e4d5c5dbd15b Mon Sep 17 00:00:00 2001 From: baodev0 Date: Thu, 15 Oct 2026 08:04:02 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20b=E1=BA=ADt/t=E1=BA=AFt=20y=C3=AAu=20c?= =?UTF-8?q?=E1=BA=A7u=20ho=C3=A0n=20t=E1=BA=A5t=20h=E1=BB=93=20s=C6=A1,=20?= =?UTF-8?q?xo=C3=A1=20d=E1=BB=8Bch=20v=E1=BB=A5=20k=C3=A8m=20=C4=91=C6=A1n?= =?UTF-8?q?/ho=C3=A1=20=C4=91=C6=A1n,=20sync=20t=C3=AAn=20VM=20=C4=91?= =?UTF-8?q?=C3=A3=20g=C3=A1n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - setting.require_complete_profile (mặc định bật), trả qua GET v1/setting - bulkDeleteServices xoá mềm invoice nâng cấp + order mồ côi (ServiceOrderRecorder::discardOrder) - createService trỏ dịch vụ sang order mới thì bỏ order cũ - SyncVirtualMachine cập nhật name + gắn virtual_machine_id cho module_vmware Co-Authored-By: Claude Opus 5.5 --- .../Api/Admin/Setting/SettingController.php | 1 + .../Api/Setting/SettingController.php | 6 +- .../Sync/SyncVirtualMachine.php | 15 ++++ .../CustomerServiceProvisioningService.php | 21 +++-- app/Services/Order/ServiceOrderRecorder.php | 15 ++++ app/Support/PublicSetting.php | 15 +++- .../DeleteCustomerServiceOrdersTest.php | 77 +++++++++++++++++++ .../Feature/Api/Client/PublicSettingTest.php | 13 +++- tests/Feature/Jobs/SyncVirtualMachineTest.php | 44 +++++++++++ 9 files changed, 198 insertions(+), 9 deletions(-) create mode 100644 tests/Feature/Api/Admin/Customer/DeleteCustomerServiceOrdersTest.php create mode 100644 tests/Feature/Jobs/SyncVirtualMachineTest.php diff --git a/app/Http/Controllers/Api/Admin/Setting/SettingController.php b/app/Http/Controllers/Api/Admin/Setting/SettingController.php index 7c068cf..9a97cf9 100644 --- a/app/Http/Controllers/Api/Admin/Setting/SettingController.php +++ b/app/Http/Controllers/Api/Admin/Setting/SettingController.php @@ -67,6 +67,7 @@ class SettingController extends Controller $validator = Validator::make($request->all(), [ 'setting' => 'sometimes|array', 'setting.api_inet' => 'sometimes|string|nullable', + 'setting.require_complete_profile' => 'sometimes|boolean', 'smtp' => 'sometimes|array', 'smtp.host' => 'sometimes|string', 'smtp.port' => 'sometimes|integer', diff --git a/app/Http/Controllers/Api/Setting/SettingController.php b/app/Http/Controllers/Api/Setting/SettingController.php index 6225277..f026ee3 100644 --- a/app/Http/Controllers/Api/Setting/SettingController.php +++ b/app/Http/Controllers/Api/Setting/SettingController.php @@ -24,7 +24,7 @@ class SettingController extends Controller * operationId="getSetting", * tags={"Settings"}, * summary="Get application settings", - * description="Returns the public application settings (title, company, logo, favi_icon, brand_color)", + * description="Returns the public application settings (title, company, logo, favi_icon, brand_color, require_complete_profile)", * @OA\Response( * response=200, * description="Successful operation", @@ -39,7 +39,9 @@ class SettingController extends Controller public function getSetting(): \Illuminate\Http\JsonResponse { return response()->json([ - 'data' => PublicSetting::values(), + 'data' => PublicSetting::values() + [ + 'require_complete_profile' => PublicSetting::requireCompleteProfile(), + ], 'message' => 'Get setting successfully', 'error' => false ]); diff --git a/app/Jobs/VirtualMachine/Sync/SyncVirtualMachine.php b/app/Jobs/VirtualMachine/Sync/SyncVirtualMachine.php index 1710426..7871ea5 100644 --- a/app/Jobs/VirtualMachine/Sync/SyncVirtualMachine.php +++ b/app/Jobs/VirtualMachine/Sync/SyncVirtualMachine.php @@ -6,6 +6,7 @@ use App\Http\Services\Telegram; use App\Http\Services\vCenter as ServiceVCenter; use App\Models\ModuleDatacenter; use App\Models\ModuleHost; +use App\Models\ModuleVmware; use App\Models\VirtualMachines; use App\Models\vCenter; use Illuminate\Bus\Queueable; @@ -178,12 +179,26 @@ class SyncVirtualMachine implements ShouldQueue $vmObj->last_synced_at = now(); $vmObj->save(); + $this->syncAssignedVm($vmObj); $syncedVmIds[] = $vmData['vm']; $count++; } return $count; } + private function syncAssignedVm(VirtualMachines $vm): void + { + ModuleVmware::query() + ->where(fn ($query) => $query + ->where('virtual_machine_id', $vm->id) + ->orWhere(fn ($legacy) => $legacy->whereNull('virtual_machine_id')->where('vmware_id', $vm->vm_id))) + ->where(fn ($query) => $query + ->whereNull('virtual_machine_id') + ->orWhereNull('name') + ->orWhere('name', '!=', $vm->name)) + ->update(['name' => $vm->name, 'virtual_machine_id' => $vm->id]); + } + /** * Delete VMs in DB that belong to the given vCenter but no longer exist on vCenter. */ diff --git a/app/Services/Customer/CustomerServiceProvisioningService.php b/app/Services/Customer/CustomerServiceProvisioningService.php index ec19bd8..3d39dfe 100644 --- a/app/Services/Customer/CustomerServiceProvisioningService.php +++ b/app/Services/Customer/CustomerServiceProvisioningService.php @@ -5,6 +5,7 @@ namespace App\Services\Customer; use App\Helpers\OrderHelper; use App\Mail\ServiceRenewalReminderMail; use App\Models\Customer; +use App\Models\Invoice; use App\Models\ModuleVmware; use App\Models\Order; use App\Models\Product; @@ -54,15 +55,21 @@ class CustomerServiceProvisioningService return $services; } - /** - * Xoá nhiều dịch vụ của khách hàng. Chỉ xoá bản ghi trong bảng `services`, - * không xoá VM (module_vmware / virtual_machines) liên kết. - */ public function bulkDeleteServices(string $uuid, array $ids): int { $customer = $this->resolveCustomer($uuid); - return Service::where('customer_id', $customer->id)->whereIn('id', $ids)->delete(); + return DB::transaction(function () use ($customer, $ids) { + $services = Service::where('customer_id', $customer->id)->whereIn('id', $ids)->get(); + + foreach ($services as $service) { + $service->delete(); + Invoice::where('service_id', $service->id)->delete(); + $this->orderRecorder->discardOrder($service->order_item_id); + } + + return $services->count(); + }); } /** @@ -286,6 +293,8 @@ class CustomerServiceProvisioningService 'server_name' => $data['server_name'] ?? null, ]; + $replacedOrderItemId = Service::where($serviceLookup)->value('order_item_id'); + $service = Service::updateOrCreate($serviceLookup, [ 'customer_id' => $customer->id, 'product_id' => $product->id, @@ -300,6 +309,8 @@ class CustomerServiceProvisioningService 'note' => $data['note'] ?? null, ]); + $this->orderRecorder->discardOrder($replacedOrderItemId); + // module_vmware.service_id chỉ biết được sau khi Service đã tồn tại. if ($vmId) { ModuleVmware::where('id', $vmId)->update(['service_id' => $service->id]); diff --git a/app/Services/Order/ServiceOrderRecorder.php b/app/Services/Order/ServiceOrderRecorder.php index 3d3382e..7b5187e 100644 --- a/app/Services/Order/ServiceOrderRecorder.php +++ b/app/Services/Order/ServiceOrderRecorder.php @@ -143,6 +143,21 @@ class ServiceOrderRecorder $invoice->order?->update(['status' => OrderStatus::CANCELLED->value]); } + public function discardOrder(?int $orderItemId): void + { + $order = $orderItemId ? OrderItem::find($orderItemId)?->order : null; + if (! $order) { + return; + } + + if (Service::whereIn('order_item_id', $order->orderItems()->pluck('id'))->exists()) { + return; + } + + $order->invoices()->delete(); + $order->delete(); + } + public function listPrice(?Product $product, string $billingCycle, ?string $serverName): ?int { if ($product?->product_type === 'domain') { diff --git a/app/Support/PublicSetting.php b/app/Support/PublicSetting.php index 914f1ce..25813be 100644 --- a/app/Support/PublicSetting.php +++ b/app/Support/PublicSetting.php @@ -13,8 +13,21 @@ final class PublicSetting */ public static function values(): array { - $value = json_decode((string) Setting::where('key', 'setting')->value('value'), true) ?: []; + $value = self::raw(); return array_map(fn (string $field) => (string) ($value[$field] ?? ''), array_combine(self::FIELDS, self::FIELDS)); } + + public static function requireCompleteProfile(): bool + { + return (bool) (self::raw()['require_complete_profile'] ?? true); + } + + /** + * @return array + */ + private static function raw(): array + { + return json_decode((string) Setting::where('key', 'setting')->value('value'), true) ?: []; + } } diff --git a/tests/Feature/Api/Admin/Customer/DeleteCustomerServiceOrdersTest.php b/tests/Feature/Api/Admin/Customer/DeleteCustomerServiceOrdersTest.php new file mode 100644 index 0000000..27056e5 --- /dev/null +++ b/tests/Feature/Api/Admin/Customer/DeleteCustomerServiceOrdersTest.php @@ -0,0 +1,77 @@ + 'Full Administrator', 'guard_name' => 'admin']); + $this->admin = User::factory()->create(); + $this->admin->assignRole('Full Administrator'); + $this->customer = Customer::factory()->create(); + $this->product = Product::factory()->create(['product_type' => 'domain']); + } + + private function createService(string $serverName): Service + { + $this->actingAs($this->admin, 'admin')->postJson( + "/api/v1/administrator/customers/{$this->customer->uuid}/services", + ['product_id' => $this->product->id, 'payment_method' => 'transfer', 'server_name' => $serverName, 'amount_paid' => 0], + )->assertCreated(); + + return Service::where('server_name', $serverName)->firstOrFail(); + } + + public function test_deleting_service_hides_its_order_and_invoices(): void + { + $kept = $this->createService('kept.vn'); + $removed = $this->createService('removed.vn'); + $removedOrderId = $removed->orderItem->order_id; + + $this->actingAs($this->admin, 'admin')->postJson( + "/api/v1/administrator/customers/{$this->customer->uuid}/services/bulk-delete", + ['ids' => [$removed->id]], + )->assertOk(); + + $this->assertSoftDeleted('orders', ['id' => $removedOrderId]); + $this->assertSoftDeleted('invoices', ['order_id' => $removedOrderId]); + $this->assertSame([$kept->orderItem->order_id], Order::pluck('id')->all()); + $this->assertSame(1, Invoice::count()); + } + + public function test_recreating_same_service_discards_previous_order(): void + { + $first = $this->createService('same.vn'); + $firstOrderId = $first->orderItem->order_id; + + $second = $this->createService('same.vn'); + + $this->assertSame($first->id, $second->id); + $this->assertSoftDeleted('orders', ['id' => $firstOrderId]); + $this->assertSame([$second->orderItem->order_id], Order::pluck('id')->all()); + $this->assertSame(1, Invoice::count()); + } +} diff --git a/tests/Feature/Api/Client/PublicSettingTest.php b/tests/Feature/Api/Client/PublicSettingTest.php index f6ca6db..2c14a03 100644 --- a/tests/Feature/Api/Client/PublicSettingTest.php +++ b/tests/Feature/Api/Client/PublicSettingTest.php @@ -27,7 +27,8 @@ class PublicSettingTest extends TestCase ->getJson('/v1/setting') ->assertOk() ->assertJsonPath('data.title', 'Portal') - ->assertJsonPath('data.brand_color', '#EF6824'); + ->assertJsonPath('data.brand_color', '#EF6824') + ->assertJsonPath('data.require_complete_profile', true); $admin = $this->getJson('/api/v1/settings/public')->assertOk(); @@ -39,4 +40,14 @@ class PublicSettingTest extends TestCase $this->assertStringNotContainsString('admin@example.com', $body); } } + + public function test_client_setting_reflects_disabled_profile_completion(): void + { + Setting::updateOrCreate(['key' => 'setting'], ['value' => json_encode(['require_complete_profile' => false])]); + + $this->withHeaders(['x-access-token' => $this->buildClientAccessToken()]) + ->getJson('/v1/setting') + ->assertOk() + ->assertJsonPath('data.require_complete_profile', false); + } } diff --git a/tests/Feature/Jobs/SyncVirtualMachineTest.php b/tests/Feature/Jobs/SyncVirtualMachineTest.php new file mode 100644 index 0000000..77eaadb --- /dev/null +++ b/tests/Feature/Jobs/SyncVirtualMachineTest.php @@ -0,0 +1,44 @@ +invokeArgs(new SyncVirtualMachine, [$vms, null, 1, 1, &$synced, &$failed]); + } + + public function test_renamed_vm_updates_assigned_module_vmware_name(): void + { + Schema::disableForeignKeyConstraints(); + $vm = VirtualMachines::create(['uuid' => (string) Str::uuid(), 'vm_id' => 'vm-1', 'name' => 'old-name', 'datacenter_id' => 1, 'host_id' => 1]); + $linked = ModuleVmware::create(['name' => 'old-name', 'vmware_id' => 'vm-1', 'virtual_machine_id' => $vm->id, 'hostname' => 'customer-host']); + $legacy = ModuleVmware::create(['name' => 'old-name', 'vmware_id' => 'vm-1']); + $other = ModuleVmware::create(['name' => 'untouched', 'vmware_id' => 'vm-2']); + + $this->sync([['vm' => 'vm-1', 'name' => 'new-name', 'power_state' => 'POWERED_ON', 'cpu_count' => 2, 'memory_size_MiB' => 2048]]); + + $this->assertSame('new-name', $vm->fresh()->name); + $this->assertSame('new-name', $linked->fresh()->name); + $this->assertSame('customer-host', $linked->fresh()->hostname); + $this->assertSame('new-name', $legacy->fresh()->name); + $this->assertSame($vm->id, (int) $legacy->fresh()->virtual_machine_id); + $this->assertSame('untouched', $other->fresh()->name); + } +}