diff --git a/app/Http/Controllers/Api/Admin/Customer/CustomerController.php b/app/Http/Controllers/Api/Admin/Customer/CustomerController.php index 462d138..39d11cd 100644 --- a/app/Http/Controllers/Api/Admin/Customer/CustomerController.php +++ b/app/Http/Controllers/Api/Admin/Customer/CustomerController.php @@ -26,6 +26,9 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Str; +use App\Enums\Invoice\InvoiceStatus; +use App\Enums\Order\OrderStatus; +use App\Helpers\OrderHelper; class CustomerController extends Controller { @@ -292,6 +295,70 @@ class CustomerController extends Controller } } + /** + * Tạo mã impersonate một lần (one-time code) để admin đăng nhập hộ khách hàng + * vào giao diện portal-client. Trả về `code` (hiệu lực 120s) — portal-client + * sẽ đổi code lấy token qua endpoint public `v1/client/impersonate/exchange`. + */ + public function loginAs(Request $request, string $uuid): JsonResponse + { + $customer = $this->customerRepository->findBy('uuid', $uuid); + + if (!$customer) { + return response()->json([ + 'error' => true, + 'message' => "Can't find customer by UUID #" . $uuid, + ], 404); + } + + if ((int) $customer->status !== 1) { + return response()->json([ + 'error' => true, + 'message' => 'Khách hàng không hoạt động, không thể đăng nhập hộ.', + ], 422); + } + + try { + $token = \Tymon\JWTAuth\Facades\JWTAuth::fromUser($customer); + $refreshToken = app(\App\Services\Api\Client\ClientAuthService::class) + ->createRefreshToken($customer->id); + + $clientResource = (new \App\Http\Resources\Api\Client\ClientResource( + $customer->loadCount(['vps', 'hosting', 'domain']) + ))->resolve($request); + + $code = Str::random(48); + \Illuminate\Support\Facades\Cache::put("impersonate:{$code}", [ + 'token' => $token, + 'refresh_token' => $refreshToken, + 'client' => $clientResource, + ], 120); + + $admin = Auth::guard('admin')->user(); + + Log::create([ + 'agent' => 'admin', + 'client_ip' => $request->ip(), + 'description' => sprintf( + '%s đăng nhập hộ khách hàng %s', + $admin->username ?? $admin->email ?? 'Admin', + $customer->email, + ), + 'customer_id' => $customer->id, + ]); + + return response()->json([ + 'error' => false, + 'data' => ['code' => $code], + ]); + } catch (\Throwable $e) { + return response()->json([ + 'error' => true, + 'message' => $e->getMessage(), + ], 500); + } + } + public function resetPassword(Request $request, string $uuid): JsonResponse { $customer = $this->customerRepository->findBy('uuid', $uuid); @@ -709,6 +776,85 @@ class CustomerController extends Controller ]); } + /** + * 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, Request $request): JsonResponse + { + $customer = $this->customerRepository->findBy('uuid', $uuid); + if (!$customer) { + return response()->json(['error' => true, 'message' => "Can't find customer"], 404); + } + + $data = $request->validate([ + 'ids' => 'required|array|min:1', + 'ids.*' => 'integer', + ]); + + $deleted = \App\Models\Service::where('customer_id', $customer->id) + ->whereIn('id', $data['ids']) + ->delete(); + + return response()->json([ + 'error' => false, + 'message' => 'Đã xoá ' . $deleted . ' dịch vụ', + 'data' => ['deleted' => $deleted], + ]); + } + + /** + * Cập nhật thông tin dịch vụ của khách hàng (server_name/hostname, status, ngày,...). + * Đồng bộ hostname sang module_vmware nếu service đã gắn VM. + */ + public function updateService(string $uuid, int $serviceId, Request $request): JsonResponse + { + $customer = $this->customerRepository->findBy('uuid', $uuid); + if (!$customer) { + return response()->json(['error' => true, 'message' => "Can't find customer"], 404); + } + + $service = \App\Models\Service::where('customer_id', $customer->id) + ->where('id', $serviceId) + ->first(); + if (!$service) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy dịch vụ'], 404); + } + + $data = $request->validate([ + 'server_name' => 'nullable|string|max:255', + 'status' => 'nullable|in:pending,active,suspended,expired,cancelled,terminated', + 'auto_renew' => 'nullable|boolean', + 'started_at' => 'nullable|date', + 'expires_at' => 'nullable|date', + 'note' => 'nullable|string', + ]); + + try { + DB::beginTransaction(); + + $service->fill($data); + $service->save(); + + // Đồng bộ hostname sang module_vmware nếu có gán VM + if ($service->vm_id && array_key_exists('server_name', $data)) { + \App\Models\ModuleVmware::where('id', $service->vm_id) + ->update(['hostname' => $data['server_name']]); + } + + DB::commit(); + + return response()->json([ + 'error' => false, + 'message' => 'Cập nhật dịch vụ thành công', + 'data' => $service->load(['product', 'orderItem', 'invoice']), + ]); + } catch (\Throwable $e) { + DB::rollBack(); + return response()->json(['error' => true, 'message' => $e->getMessage()], 500); + } + } + /** * Create a service for a customer. For VPS products, accepts a * `virtual_machine_id` from the vCenter inventory and auto-creates the @@ -730,9 +876,14 @@ class CustomerController extends Controller 'started_at' => 'nullable|date', 'expires_at' => 'nullable|date', 'note' => 'nullable|string', + // New: billing & payment + 'billing_cycle' => 'nullable|in:monthly,quarterly,semi_annually,annually,biennially,triennially', + 'payment_method' => 'required|in:transfer,balance', + 'proof_images' => 'nullable|array', + 'proof_images.*' => 'file|image|max:5120', ]); - $product = \App\Models\Product::find($data['product_id']); + $product = \App\Models\Product::with('pricings')->find($data['product_id']); if ($product->product_type === 'vps' && empty($data['virtual_machine_id'])) { return response()->json([ 'error' => true, @@ -740,35 +891,164 @@ class CustomerController extends Controller ], 422); } + $billingCycle = $data['billing_cycle'] ?? 'monthly'; + $months = $this->cycleToMonths($billingCycle); + + $pricing = $product->pricings + ->where('billing_cycle', $billingCycle) + ->first(); + $basePrice = (int) ($pricing->price ?? 0); + + $paymentMethod = $data['payment_method']; + + // Đối với balance: kiểm tra số dư trước khi vào transaction + if ($paymentMethod === 'balance' && (int) $customer->total < $basePrice) { + return response()->json([ + 'error' => true, + 'message' => sprintf( + 'Số dư khách không đủ. Hiện có: %s VNĐ, cần: %s VNĐ.', + number_format((int) $customer->total), + number_format($basePrice) + ), + ], 422); + } + + // Upload proof images (chuyển khoản) + $proofPaths = []; + if ($paymentMethod === 'transfer' && $request->hasFile('proof_images')) { + foreach ($request->file('proof_images') as $file) { + $proofPaths[] = $file->store('payment-proofs', 'public'); + } + } + try { DB::beginTransaction(); + // 1) ModuleVmware: snapshot từ virtual_machines $vmId = null; if (!empty($data['virtual_machine_id'])) { - $vm = \App\Models\VirtualMachines::find($data['virtual_machine_id']); - $moduleVm = \App\Models\ModuleVmware::create([ - 'customer_id' => $customer->id, - 'name' => $vm->name, - 'vmware_id' => $vm->vm_id, - 'virtual_machine_id' => $vm->id, - 'hostname' => $data['server_name'] ?? $vm->name, - 'status' => 1, - ]); + $vm = \App\Models\VirtualMachines::with('datacenter') + ->find($data['virtual_machine_id']); + + $vcenterId = $vm->datacenter->vcenter_id ?? null; + + $moduleVm = \App\Models\ModuleVmware::updateOrCreate( + ['virtual_machine_id' => $vm->id], + [ + 'customer_id' => $customer->id, + 'name' => $vm->name, + 'vmware_id' => $vm->vm_id, + 'vcenter_id' => $vcenterId, + 'hostname' => $data['server_name'] ?? $vm->name, + 'cpu' => $vm->cpu, + 'ram_mb' => $vm->ram_mb, + 'disk_gb' => $vm->disk_gb ?? null, + 'status' => 1, + ] + ); $vmId = $moduleVm->id; } - $service = \App\Models\Service::create([ - 'customer_id' => $customer->id, - 'product_id' => $data['product_id'], - 'vm_id' => $vmId, - 'server_name' => $data['server_name'] ?? null, - 'status' => $data['status'] ?? 'active', - 'auto_renew' => (bool) ($data['auto_renew'] ?? false), - 'started_at' => $data['started_at'] ?? now(), - 'expires_at' => $data['expires_at'] ?? null, - 'note' => $data['note'] ?? null, + // 2) Order + OrderItem (mô phỏng flow customer đặt hàng) + $order = Order::create([ + 'order_num' => OrderHelper::generateOrderNumber(), + 'customer_id' => $customer->id, + 'sub_total' => $basePrice, + 'discount_amount' => 0, + 'total' => $basePrice, + 'status' => OrderStatus::ACTIVE->value, + 'payment_method' => $paymentMethod, + 'client_ip' => $request->ip(), ]); + $orderItem = \App\Models\OrderItem::create([ + 'order_id' => $order->id, + 'product_id' => $product->id, + 'billing_cycle' => $billingCycle, + 'months' => $months, + 'server_name' => $data['server_name'] ?? null, + 'quantity' => 1, + 'price' => $basePrice, + ]); + + // 3) Invoice — đánh dấu PAID luôn vì admin đứng ra tạo & xác nhận thanh toán + $invoice = Invoice::create([ + 'invoice_number' => OrderHelper::generateOrderNumber('INV'), + 'customer_id' => $customer->id, + 'order_id' => $order->id, + 'invoice_type' => 1, + 'sub_total' => $basePrice, + 'discount_amount' => 0, + 'tax' => 0, + 'total' => $basePrice, + 'status' => InvoiceStatus::PAID->value, + 'payment_method' => $paymentMethod, + 'paid_at' => now(), + 'due_date' => now()->addMonths($months)->toDateString(), + ]); + + \App\Models\InvoiceItem::create([ + 'invoice_id' => $invoice->id, + 'product_id' => $product->id, + 'type' => 'service', + 'description' => $product->name ?? 'Dịch vụ', + 'months' => $months, + 'quantity' => 1, + 'unit_price' => $basePrice, + 'total_price' => $basePrice, + ]); + + // 4) Payment processing + if ($paymentMethod === 'balance') { + $customer->total = (int) $customer->total - $basePrice; + $customer->save(); + } + + Transaction::create([ + 'uuid' => (string) Str::uuid(), + 'customer_id' => $customer->id, + 'user_id' => Auth::id(), + 'trans' => $order->order_num, + 'amount' => $basePrice, + 'type' => $paymentMethod === 'transfer' ? 'transfer' : 'balance', + 'status' => 1, // paid + 'note' => $paymentMethod === 'transfer' + ? 'Admin tạo dịch vụ - thanh toán chuyển khoản' + : 'Admin tạo dịch vụ - trừ số dư khách hàng', + 'proof_image' => !empty($proofPaths) ? json_encode($proofPaths) : null, + ]); + + // 5) Service — updateOrCreate để tránh trùng khi cùng VM được gán lại + $startedAt = $data['started_at'] ?? now(); + $expiresAt = $data['expires_at'] + ?? \Carbon\Carbon::parse($startedAt)->addMonths($months); + + $serviceLookup = $vmId + ? ['customer_id' => $customer->id, 'vm_id' => $vmId] + : [ + 'customer_id' => $customer->id, + 'product_id' => $product->id, + 'vm_id' => null, + 'server_name' => $data['server_name'] ?? null, + ]; + + $service = \App\Models\Service::updateOrCreate( + $serviceLookup, + [ + 'customer_id' => $customer->id, + 'product_id' => $product->id, + 'vm_id' => $vmId, + 'order_item_id' => $orderItem->id, + 'invoice_id' => $invoice->id, + 'server_name' => $data['server_name'] ?? null, + 'status' => $data['status'] ?? 'active', + 'auto_renew' => (bool) ($data['auto_renew'] ?? false), + 'started_at' => $startedAt, + 'expires_at' => $expiresAt, + 'note' => $data['note'] ?? null, + ] + ); + DB::commit(); return response()->json([ 'error' => false, @@ -843,4 +1123,17 @@ class CustomerController extends Controller 'data' => $orders ]); } + + private function cycleToMonths(?string $cycle): int + { + return match ($cycle) { + 'monthly' => 1, + 'quarterly' => 3, + 'semi_annually' => 6, + 'annually' => 12, + 'biennially' => 24, + 'triennially' => 36, + default => 1, + }; + } } diff --git a/app/Http/Controllers/Api/Client/ClientController.php b/app/Http/Controllers/Api/Client/ClientController.php index cc28e31..51d7167 100644 --- a/app/Http/Controllers/Api/Client/ClientController.php +++ b/app/Http/Controllers/Api/Client/ClientController.php @@ -309,6 +309,37 @@ class ClientController extends Controller } } + /** + * Đổi mã impersonate (do admin cấp) lấy access token + refresh token. + * Endpoint public — code chỉ dùng được 1 lần và hết hạn sau 120s. + */ + public function impersonateExchange(Request $request) + { + $request->validate([ + 'code' => ['required', 'string', 'max:128'], + ]); + + $code = (string) $request->input('code'); + $payload = \Illuminate\Support\Facades\Cache::pull("impersonate:{$code}"); + + if (!$payload) { + return response()->json([ + 'error' => true, + 'message' => 'Mã đăng nhập không hợp lệ hoặc đã hết hạn.', + ], 401); + } + + return response()->json([ + 'error' => false, + 'data' => [ + 'token' => $payload['token'], + 'refresh_token' => $payload['refresh_token'], + 'client' => $payload['client'], + 'expires_in' => config('jwt.ttl', 60) * 60, + ], + ]); + } + public function forgotPassword(Request $request) { $request->validate([ diff --git a/app/Http/Resources/Api/Service/VpsServiceResource.php b/app/Http/Resources/Api/Service/VpsServiceResource.php index 8de3ab8..2fa16ba 100644 --- a/app/Http/Resources/Api/Service/VpsServiceResource.php +++ b/app/Http/Resources/Api/Service/VpsServiceResource.php @@ -17,7 +17,7 @@ class VpsServiceResource extends JsonResource $orderItem = $this->orderItem; $invoice = $this->invoice; $order = $orderItem?->order ?? $invoice?->order; - $product = $orderItem?->product; + $product = $orderItem?->product ?? $this->product; return [ 'id' => $this->id, diff --git a/app/Jobs/VirtualMachine/Sync/SyncVirtualMachine.php b/app/Jobs/VirtualMachine/Sync/SyncVirtualMachine.php index 59ec56c..1710426 100644 --- a/app/Jobs/VirtualMachine/Sync/SyncVirtualMachine.php +++ b/app/Jobs/VirtualMachine/Sync/SyncVirtualMachine.php @@ -46,6 +46,8 @@ class SyncVirtualMachine implements ShouldQueue $vCenterCount = 0; $vmTotalCount = 0; + $totalDeletedCount = 0; + foreach ($data as $item) { Log::info("Processing vCenter: {$item->host}"); $this->vCenter->apiUri = $item->host; @@ -65,24 +67,27 @@ class SyncVirtualMachine implements ShouldQueue continue; } + $syncedVmIds = []; + $fetchFailed = false; + $datacenters = ModuleDatacenter::where('vcenter_id', $item->id)->get(); if ($datacenters->count() > 0) { foreach ($datacenters as $dc) { // Get all hosts for this datacenter $hosts = ModuleHost::where('module_datacenter_id', $dc->id)->get(); - + if ($hosts->count() > 0) { foreach ($hosts as $host) { Log::info("Fetching VMs for host: {$host->host_id} in datacenter: {$dc->name}"); $vms = $this->vCenter->getAllVirtualMachine($dc->datacenter_id, $host->host_id); - $vmCount = $this->processVMs($vms, $item, $dc->id, $host->id); + $vmCount = $this->processVMs($vms, $item, $dc->id, $host->id, $syncedVmIds, $fetchFailed); $vmTotalCount += $vmCount; } } else { // Fallback: Sync VMs by datacenter only Log::info("No hosts found for datacenter {$dc->name}, fetching VMs by datacenter only"); $vms = $this->vCenter->getAllVirtualMachine($dc->datacenter_id); - $vmCount = $this->processVMs($vms, $item, $dc->id, null); + $vmCount = $this->processVMs($vms, $item, $dc->id, null, $syncedVmIds, $fetchFailed); $vmTotalCount += $vmCount; } } @@ -90,10 +95,20 @@ class SyncVirtualMachine implements ShouldQueue // Fallback: Sync all VMs without datacenter context Log::info("No datacenters found, fetching all VMs for {$item->host}"); $vms = $this->vCenter->getAllVirtualMachine(); - $vmCount = $this->processVMs($vms, $item, null, null); + $vmCount = $this->processVMs($vms, $item, null, null, $syncedVmIds, $fetchFailed); $vmTotalCount += $vmCount; } + // Cleanup stale VMs in DB that no longer exist on vCenter. + // Only run when all fetches succeeded to avoid wiping data on transient failures. + if (!$fetchFailed) { + $datacenterIds = $datacenters->pluck('id')->all(); + $deletedCount = $this->cleanupStaleVMs($datacenterIds, $syncedVmIds, $item); + $totalDeletedCount += $deletedCount; + } else { + Log::warning("Skip cleanup for vCenter {$item->host} because some VM fetches failed."); + } + $vCenterCount++; // Increment vCenter synced count } @@ -102,6 +117,7 @@ class SyncVirtualMachine implements ShouldQueue 'message' => 'Sync VMs completed successfully', 'synced_vcenter_count' => $vCenterCount, 'synced_vm_count' => $vmTotalCount, + 'deleted_vm_count' => $totalDeletedCount, ]); } catch (Throwable $e) { @@ -117,7 +133,7 @@ class SyncVirtualMachine implements ShouldQueue } } - private function processVMs($vms, $vCenterItem, $datacenterId, $hostId) + private function processVMs($vms, $vCenterItem, $datacenterId, $hostId, array &$syncedVmIds, bool &$fetchFailed) { // If token expired, retry login (handled inside loop in previous logic, but simpler here if we just handle the array check) if ($vms === 'Login_Again') { @@ -139,6 +155,7 @@ class SyncVirtualMachine implements ShouldQueue if (!is_array($vms)) { Log::warning("Failed to fetch VMs or empty response. Response: " . json_encode($vms)); + $fetchFailed = true; return 0; } @@ -161,8 +178,48 @@ class SyncVirtualMachine implements ShouldQueue $vmObj->last_synced_at = now(); $vmObj->save(); + $syncedVmIds[] = $vmData['vm']; $count++; } return $count; } + + /** + * Delete VMs in DB that belong to the given vCenter but no longer exist on vCenter. + */ + private function cleanupStaleVMs(array $datacenterIds, array $syncedVmIds, $vCenterItem): int + { + $syncedVmIds = array_values(array_unique($syncedVmIds)); + + if (!empty($datacenterIds)) { + $query = VirtualMachines::whereIn('datacenter_id', $datacenterIds); + } else { + // No datacenter context: fallback - chỉ xoá khi đã sync được ít nhất 1 VM + // để tránh xoá nhầm khi vCenter rỗng do lỗi. + if (empty($syncedVmIds)) { + Log::warning("Skip cleanup for {$vCenterItem->host}: no datacenters and no synced VMs."); + return 0; + } + $query = VirtualMachines::query(); + } + + if (!empty($syncedVmIds)) { + $query->whereNotIn('vm_id', $syncedVmIds); + } + + $staleVms = $query->get(['id', 'vm_id', 'name']); + + if ($staleVms->isEmpty()) { + return 0; + } + + foreach ($staleVms as $stale) { + Log::info("Deleting stale VM removed from vCenter {$vCenterItem->host}: {$stale->name} ({$stale->vm_id})"); + } + + $deleted = VirtualMachines::whereIn('id', $staleVms->pluck('id')->all())->delete(); + Log::info("Cleanup vCenter {$vCenterItem->host}: deleted {$deleted} stale VM(s)."); + + return (int) $deleted; + } } diff --git a/app/Models/ModuleVmware.php b/app/Models/ModuleVmware.php index d2d57e7..38c7abc 100644 --- a/app/Models/ModuleVmware.php +++ b/app/Models/ModuleVmware.php @@ -34,6 +34,9 @@ class ModuleVmware extends Model 'dns', 'plain_password', 'virtual_machine_id', + 'cpu', + 'ram_mb', + 'disk_gb', ]; // NOTE: password must NOT use 'hashed' cast — it is a VM guest credential diff --git a/app/Repositories/Api/ServiceClient/ServiceClientRepository.php b/app/Repositories/Api/ServiceClient/ServiceClientRepository.php index 79d3ef2..0b77e48 100644 --- a/app/Repositories/Api/ServiceClient/ServiceClientRepository.php +++ b/app/Repositories/Api/ServiceClient/ServiceClientRepository.php @@ -19,6 +19,7 @@ class ServiceClientRepository extends BaseApiRepository implements ServiceClient { $query = $this->model ->with([ + 'product', 'invoice.order', 'orderItem' => fn($q) => $q->with(['product', 'os.osGroup', 'order']), ]) @@ -27,7 +28,7 @@ class ServiceClientRepository extends BaseApiRepository implements ServiceClient ->orderBy('started_at', 'desc'); if ($productType) { - $query->whereHas('orderItem.product', function ($builder) use ($productType) { + $query->whereHas('product', function ($builder) use ($productType) { $builder->where('product_type', $productType); }); } diff --git a/composer.json b/composer.json index baba989..02a9314 100644 --- a/composer.json +++ b/composer.json @@ -38,7 +38,8 @@ "psr-4": { "App\\": "app/", "Database\\Factories\\": "database/factories/", - "Database\\Seeders\\": "database/seeders/" + "Database\\Seeders\\": "database/seeders/", + "Modules\\": "modules/" }, "files": [ "app/Http/Helpers/function_helper.php" diff --git a/database/migrations/2026_06_25_000001_add_specs_to_module_vmware.php b/database/migrations/2026_06_25_000001_add_specs_to_module_vmware.php new file mode 100644 index 0000000..98bb607 --- /dev/null +++ b/database/migrations/2026_06_25_000001_add_specs_to_module_vmware.php @@ -0,0 +1,54 @@ +integer('cpu')->nullable()->after('hostname'); + } + if (!Schema::hasColumn('module_vmware', 'ram_mb')) { + $table->integer('ram_mb')->nullable()->after('cpu'); + } + if (!Schema::hasColumn('module_vmware', 'disk_gb')) { + $table->integer('disk_gb')->nullable()->after('ram_mb'); + } + }); + + // Backfill vcenter_id, cpu, ram_mb from virtual_machines for rows that are + // linked via virtual_machine_id but missing those fields. + if ( + Schema::hasTable('module_vmware') + && Schema::hasTable('virtual_machines') + && Schema::hasTable('module_datacenter') + && Schema::hasColumn('module_vmware', 'virtual_machine_id') + ) { + DB::statement(' + UPDATE module_vmware mv + INNER JOIN virtual_machines vm ON vm.id = mv.virtual_machine_id + LEFT JOIN module_datacenter dc ON dc.id = vm.datacenter_id + SET + mv.vcenter_id = COALESCE(mv.vcenter_id, dc.vcenter_id), + mv.cpu = COALESCE(mv.cpu, vm.cpu), + mv.ram_mb = COALESCE(mv.ram_mb, vm.ram_mb) + WHERE mv.virtual_machine_id IS NOT NULL + '); + } + } + + public function down(): void + { + Schema::table('module_vmware', function (Blueprint $table) { + foreach (['cpu', 'ram_mb', 'disk_gb'] as $col) { + if (Schema::hasColumn('module_vmware', $col)) { + $table->dropColumn($col); + } + } + }); + } +}; diff --git a/modules/DomainFluctuation/App/DTOs/DomainFluctuationDTO.php b/modules/DomainFluctuation/App/DTOs/DomainFluctuationDTO.php new file mode 100644 index 0000000..acfad0a --- /dev/null +++ b/modules/DomainFluctuation/App/DTOs/DomainFluctuationDTO.php @@ -0,0 +1,122 @@ + $this->domainName, + 'term_id' => $this->termId, + 'ownerName' => $this->ownerName, + 'ownerType' => $this->ownerType, + 'address' => $this->address, + 'countryCode' => $this->countryCode, + 'adminName' => $this->adminName, + 'phone' => $this->phone, + 'email' => $this->email, + 'registeredDate' => $this->registeredDate, + 'expiredDate' => $this->expiredDate, + 'wardName' => $this->wardName, + 'districtName' => $this->districtName, + 'cityName' => $this->cityName, + 'cityCode' => $this->cityCode, + 'fax' => $this->fax, + 'businessCode' => $this->businessCode, + 'eidCode' => $this->eidCode, + 'personalId' => $this->personalId, + 'birthDate' => $this->birthDate, + 'icannRegistrarName' => $this->icannRegistrarName, + 'icannRegistrarTradeName' => $this->icannRegistrarTradeName, + 'isAgentManager' => $this->isAgentManager, + 'action' => $this->action, + 'actionReason' => $this->actionReason, + 'actionSource' => $this->actionSource, + 'actionDate' => $this->actionDate, + 'note' => $this->note, + 'ianaNumber' => $this->ianaNumber, + 'status' => $this->status, + ], static fn ($value) => $value !== null); + } + + private static function toTimestamp(int|string $value): int + { + if (is_int($value) || ctype_digit((string) $value)) { + return (int) $value; + } + + return Carbon::parse($value)->timestamp; + } +} diff --git a/modules/DomainFluctuation/App/DTOs/DomainFluctuationFilterDTO.php b/modules/DomainFluctuation/App/DTOs/DomainFluctuationFilterDTO.php new file mode 100644 index 0000000..7d23bf4 --- /dev/null +++ b/modules/DomainFluctuation/App/DTOs/DomainFluctuationFilterDTO.php @@ -0,0 +1,37 @@ +timestamp; + } + + return new self( + termName: $data['term_name'], + description: $data['description'] ?? '', + dateCreate: (int) $dateCreate, + userId: (int) $data['user_id'], + status: (bool) ($data['status'] ?? false), + type: $data['type'] ?? 'vnnic_06', + ); + } + + public function toArray(): array + { + return [ + 'term_name' => $this->termName, + 'description' => $this->description, + 'date_create' => $this->dateCreate, + 'user_id' => $this->userId, + 'status' => $this->status, + ]; + } +} diff --git a/modules/DomainFluctuation/App/Http/Controllers/.gitkeep b/modules/DomainFluctuation/App/Http/Controllers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/modules/DomainFluctuation/App/Http/Controllers/Api/Admin/DomainFluctuationController.php b/modules/DomainFluctuation/App/Http/Controllers/Api/Admin/DomainFluctuationController.php new file mode 100644 index 0000000..0f90834 --- /dev/null +++ b/modules/DomainFluctuation/App/Http/Controllers/Api/Admin/DomainFluctuationController.php @@ -0,0 +1,138 @@ +toDTO(); + + $query = DomainFluctuation::query()->with('term'); + + if ($filter->termId) { + $query->where('term_id', $filter->termId); + } + + if ($filter->keyword) { + $kw = '%' . $filter->keyword . '%'; + $query->where(function ($q) use ($kw) { + $q->where('domainName', 'like', $kw) + ->orWhere('ownerName', 'like', $kw) + ->orWhere('email', 'like', $kw) + ->orWhere('phone', 'like', $kw); + }); + } + + if ($filter->ownerType) { + $query->where('ownerType', $filter->ownerType); + } + + if ($filter->status !== null) { + $query->where('status', $filter->status); + } + + if ($filter->action) { + $query->where('action', $filter->action); + } + + if ($filter->from) { + $query->where('registeredDate', '>=', $filter->from); + } + + if ($filter->to) { + $query->where('registeredDate', '<=', $filter->to); + } + + $query->orderBy($filter->sortBy, $filter->sortDir); + + $paginator = $query->paginate($filter->perPage, ['*'], 'page', $filter->page); + + return DomainFluctuationResource::collection($paginator)->response(); + } + + public function show(int $id): JsonResponse + { + $domain = DomainFluctuation::with('term')->find($id); + + if (! $domain) { + return response()->json([ + 'success' => false, + 'message' => 'Không tìm thấy tên miền', + ], 404); + } + + return response()->json([ + 'success' => true, + 'data' => DomainFluctuationResource::make($domain), + ]); + } + + public function store(StoreDomainFluctuationRequest $request): JsonResponse + { + $dto = $request->toDTO(); + + $domain = DB::transaction(fn () => DomainFluctuation::create($dto->toArray())); + + return response()->json([ + 'success' => true, + 'message' => 'Tạo tên miền thành công', + 'data' => DomainFluctuationResource::make($domain), + ], 201); + } + + public function update(int $id, UpdateDomainFluctuationRequest $request): JsonResponse + { + $domain = DomainFluctuation::find($id); + + if (! $domain) { + return response()->json([ + 'success' => false, + 'message' => 'Không tìm thấy tên miền', + ], 404); + } + + try { + DB::transaction(fn () => $domain->update($request->toDTO()->toArray())); + } catch (\Throwable $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 500); + } + + return response()->json([ + 'success' => true, + 'message' => 'Cập nhật tên miền thành công', + 'data' => DomainFluctuationResource::make($domain->fresh('term')), + ]); + } + + public function destroy(int $id): JsonResponse + { + $domain = DomainFluctuation::find($id); + + if (! $domain) { + return response()->json([ + 'success' => false, + 'message' => 'Không tìm thấy tên miền', + ], 404); + } + + $domain->delete(); + + return response()->json([ + 'success' => true, + 'message' => 'Xoá tên miền thành công', + ]); + } +} diff --git a/modules/DomainFluctuation/App/Http/Controllers/Api/Admin/TermController.php b/modules/DomainFluctuation/App/Http/Controllers/Api/Admin/TermController.php new file mode 100644 index 0000000..eadcdf8 --- /dev/null +++ b/modules/DomainFluctuation/App/Http/Controllers/Api/Admin/TermController.php @@ -0,0 +1,116 @@ +input('per_page', 20); + + $terms = DomainFluctuationTerm::query() + ->with('type') + ->withCount('fluctuations') + ->orderByDesc('id') + ->paginate($perPage); + + return DomainFluctuationTermResource::collection($terms)->response(); + } + + public function show(int $id): JsonResponse + { + $term = DomainFluctuationTerm::with('type')->withCount('fluctuations')->find($id); + + if (! $term) { + return response()->json([ + 'success' => false, + 'message' => 'Không tìm thấy đợt khai báo', + ], 404); + } + + return response()->json([ + 'success' => true, + 'data' => DomainFluctuationTermResource::make($term), + ]); + } + + public function store(StoreTermRequest $request): JsonResponse + { + $userId = (int) ($request->user()?->id ?? 0); + $dto = $request->toDTO($userId); + + $term = DB::transaction(function () use ($dto, $request) { + $term = DomainFluctuationTerm::create($dto->toArray()); + + TermFluctuationType::create([ + 'term_fluctuation_id' => $term->id, + 'type' => $request->input('type', 'vnnic_06'), + ]); + + return $term->load('type'); + }); + + return response()->json([ + 'success' => true, + 'message' => 'Tạo đợt khai báo thành công', + 'data' => DomainFluctuationTermResource::make($term), + ], 201); + } + + public function update(int $id, StoreTermRequest $request): JsonResponse + { + $term = DomainFluctuationTerm::find($id); + + if (! $term) { + return response()->json([ + 'success' => false, + 'message' => 'Không tìm thấy đợt khai báo', + ], 404); + } + + DB::transaction(function () use ($term, $request) { + $term->update($request->only(['term_name', 'description', 'date_create', 'status'])); + + if ($request->filled('type')) { + TermFluctuationType::updateOrCreate( + ['term_fluctuation_id' => $term->id], + ['type' => $request->input('type')], + ); + } + }); + + return response()->json([ + 'success' => true, + 'message' => 'Cập nhật đợt khai báo thành công', + 'data' => DomainFluctuationTermResource::make($term->fresh('type')), + ]); + } + + public function destroy(int $id): JsonResponse + { + $term = DomainFluctuationTerm::find($id); + + if (! $term) { + return response()->json([ + 'success' => false, + 'message' => 'Không tìm thấy đợt khai báo', + ], 404); + } + + $term->delete(); + + return response()->json([ + 'success' => true, + 'message' => 'Xoá đợt khai báo thành công', + ]); + } +} diff --git a/modules/DomainFluctuation/App/Http/Controllers/Api/Client/DomainFluctuationController.php b/modules/DomainFluctuation/App/Http/Controllers/Api/Client/DomainFluctuationController.php new file mode 100644 index 0000000..7f7f8e9 --- /dev/null +++ b/modules/DomainFluctuation/App/Http/Controllers/Api/Client/DomainFluctuationController.php @@ -0,0 +1,56 @@ +toDTO(); + + $query = DomainFluctuation::query() + ->with('term') + ->where('status', 1); + + if ($filter->termId) { + $query->where('term_id', $filter->termId); + } + + if ($filter->keyword) { + $kw = '%' . $filter->keyword . '%'; + $query->where(function ($q) use ($kw) { + $q->where('domainName', 'like', $kw) + ->orWhere('ownerName', 'like', $kw); + }); + } + + $query->orderBy($filter->sortBy, $filter->sortDir); + + $paginator = $query->paginate($filter->perPage, ['*'], 'page', $filter->page); + + return DomainFluctuationResource::collection($paginator)->response(); + } + + public function show(int $id): JsonResponse + { + $domain = DomainFluctuation::with('term')->where('status', 1)->find($id); + + if (! $domain) { + return response()->json([ + 'success' => false, + 'message' => 'Không tìm thấy tên miền', + ], 404); + } + + return response()->json([ + 'success' => true, + 'data' => DomainFluctuationResource::make($domain), + ]); + } +} diff --git a/modules/DomainFluctuation/App/Http/Requests/IndexDomainFluctuationRequest.php b/modules/DomainFluctuation/App/Http/Requests/IndexDomainFluctuationRequest.php new file mode 100644 index 0000000..6f59788 --- /dev/null +++ b/modules/DomainFluctuation/App/Http/Requests/IndexDomainFluctuationRequest.php @@ -0,0 +1,36 @@ + ['nullable', 'integer'], + 'keyword' => ['nullable', 'string', 'max:255'], + 'ownerType' => ['nullable', 'in:C,T'], + 'status' => ['nullable', 'integer', 'in:0,1'], + 'action' => ['nullable', 'string', 'max:100'], + 'from' => ['nullable', 'integer'], + 'to' => ['nullable', 'integer'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:200'], + 'page' => ['nullable', 'integer', 'min:1'], + 'sort_by' => ['nullable', 'in:id,domainName,registeredDate,expiredDate,actionDate,created_at'], + 'sort_dir' => ['nullable', 'in:asc,desc'], + ]; + } + + public function toDTO(): DomainFluctuationFilterDTO + { + return DomainFluctuationFilterDTO::fromArray($this->validated()); + } +} diff --git a/modules/DomainFluctuation/App/Http/Requests/StoreDomainFluctuationRequest.php b/modules/DomainFluctuation/App/Http/Requests/StoreDomainFluctuationRequest.php new file mode 100644 index 0000000..ef77121 --- /dev/null +++ b/modules/DomainFluctuation/App/Http/Requests/StoreDomainFluctuationRequest.php @@ -0,0 +1,75 @@ + ['required', 'string', 'max:255'], + 'term_id' => ['required', 'integer', 'exists:domain_fluctuation_terms,id'], + 'ownerName' => ['required', 'string', 'max:255'], + 'ownerType' => ['required', 'in:C,T'], + 'address' => ['required', 'string'], + 'countryCode' => ['required', 'string', 'max:10'], + 'adminName' => ['required', 'string', 'max:255'], + 'phone' => ['required', 'string', 'max:50'], + 'email' => ['required', 'email', 'max:255'], + 'registeredDate' => ['required'], + 'expiredDate' => ['required'], + 'cityName' => ['nullable', 'string', 'max:255'], + 'cityCode' => ['nullable', 'string', 'max:50'], + 'districtName' => ['nullable', 'string', 'max:255'], + 'wardName' => ['nullable', 'string', 'max:255'], + 'fax' => ['nullable', 'string', 'max:50'], + 'icannRegistrarName' => ['nullable', 'string', 'max:255'], + 'icannRegistrarTradeName' => ['nullable', 'string', 'max:255'], + 'businessCode' => ['nullable', 'string', 'max:100'], + 'eidCode' => ['nullable', 'string', 'max:100'], + 'personalId' => ['nullable', 'string', 'max:50'], + 'birthDate' => ['nullable', 'string', 'max:20'], + 'isAgentManager' => ['nullable', 'boolean'], + 'action' => ['nullable', 'string', 'max:100'], + 'actionReason' => ['nullable', 'string'], + 'actionSource' => ['nullable', 'string', 'max:100'], + 'actionDate' => ['nullable'], + 'note' => ['nullable', 'string'], + 'ianaNumber' => ['nullable', 'integer'], + 'status' => ['nullable', 'integer', 'in:0,1'], + ]; + } + + public function messages(): array + { + return [ + 'domainName.required' => 'Tên miền là bắt buộc', + 'term_id.required' => 'Đợt khai báo là bắt buộc', + 'term_id.exists' => 'Đợt khai báo không tồn tại', + 'ownerName.required' => 'Tên chủ sở hữu là bắt buộc', + 'ownerType.required' => 'Loại chủ sở hữu là bắt buộc', + 'ownerType.in' => 'Loại chủ sở hữu phải là C (cá nhân) hoặc T (tổ chức)', + 'registeredDate.required' => 'Ngày đăng ký là bắt buộc', + 'expiredDate.required' => 'Ngày hết hạn là bắt buộc', + 'address.required' => 'Địa chỉ là bắt buộc', + 'countryCode.required' => 'Mã quốc gia là bắt buộc', + 'adminName.required' => 'Tên quản trị là bắt buộc', + 'phone.required' => 'Số điện thoại là bắt buộc', + 'email.required' => 'Email là bắt buộc', + 'email.email' => 'Email không hợp lệ', + ]; + } + + public function toDTO(): DomainFluctuationDTO + { + return DomainFluctuationDTO::fromArray($this->validated()); + } +} diff --git a/modules/DomainFluctuation/App/Http/Requests/StoreTermRequest.php b/modules/DomainFluctuation/App/Http/Requests/StoreTermRequest.php new file mode 100644 index 0000000..71c4d70 --- /dev/null +++ b/modules/DomainFluctuation/App/Http/Requests/StoreTermRequest.php @@ -0,0 +1,40 @@ + ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'date_create' => ['nullable'], + 'status' => ['nullable', 'boolean'], + 'type' => ['nullable', 'in:vnnic_06,vnnic_07'], + ]; + } + + public function messages(): array + { + return [ + 'term_name.required' => 'Tên đợt khai báo là bắt buộc', + ]; + } + + public function toDTO(int $userId): DomainFluctuationTermDTO + { + return DomainFluctuationTermDTO::fromArray(array_merge( + $this->validated(), + ['user_id' => $userId] + )); + } +} diff --git a/modules/DomainFluctuation/App/Http/Requests/UpdateDomainFluctuationRequest.php b/modules/DomainFluctuation/App/Http/Requests/UpdateDomainFluctuationRequest.php new file mode 100644 index 0000000..ed21649 --- /dev/null +++ b/modules/DomainFluctuation/App/Http/Requests/UpdateDomainFluctuationRequest.php @@ -0,0 +1,20 @@ + ['sometimes', 'integer', 'exists:domain_fluctuation_terms,id'], + ]); + } + + public function toDTO(): DomainFluctuationDTO + { + return DomainFluctuationDTO::fromArray($this->validated()); + } +} diff --git a/modules/DomainFluctuation/App/Http/Resources/DomainFluctuationResource.php b/modules/DomainFluctuation/App/Http/Resources/DomainFluctuationResource.php new file mode 100644 index 0000000..8806d50 --- /dev/null +++ b/modules/DomainFluctuation/App/Http/Resources/DomainFluctuationResource.php @@ -0,0 +1,49 @@ + $this->id, + 'domainName' => $this->domainName, + 'term_id' => $this->term_id, + 'term' => DomainFluctuationTermResource::make($this->whenLoaded('term')), + 'registeredDate' => $this->registeredDate, + 'expiredDate' => $this->expiredDate, + 'ownerName' => $this->ownerName, + 'ownerType' => $this->ownerType, + 'address' => $this->address, + 'wardName' => $this->wardName, + 'districtName' => $this->districtName, + 'cityName' => $this->cityName, + 'cityCode' => $this->cityCode, + 'countryCode' => $this->countryCode, + 'phone' => $this->phone, + 'email' => $this->email, + 'fax' => $this->fax, + 'adminName' => $this->adminName, + 'businessCode' => $this->businessCode, + 'eidCode' => $this->eidCode, + 'personalId' => $this->personalId, + 'birthDate' => $this->birthDate, + 'icannRegistrarName' => $this->icannRegistrarName, + 'icannRegistrarTradeName' => $this->icannRegistrarTradeName, + 'isAgentManager' => (bool) $this->isAgentManager, + 'action' => $this->action, + 'actionReason' => $this->actionReason, + 'actionSource' => $this->actionSource, + 'actionDate' => $this->actionDate, + 'note' => $this->note, + 'ianaNumber' => $this->ianaNumber, + 'status' => (int) $this->status, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/modules/DomainFluctuation/App/Http/Resources/DomainFluctuationTermResource.php b/modules/DomainFluctuation/App/Http/Resources/DomainFluctuationTermResource.php new file mode 100644 index 0000000..c5192d1 --- /dev/null +++ b/modules/DomainFluctuation/App/Http/Resources/DomainFluctuationTermResource.php @@ -0,0 +1,28 @@ + $this->id, + 'term_name' => $this->term_name, + 'description' => $this->description, + 'date_create' => $this->date_create, + 'user_id' => $this->user_id, + 'status' => (bool) $this->status, + 'type' => $this->whenLoaded('type', fn () => $this->type?->type), + 'fluctuations_count' => $this->when( + isset($this->fluctuations_count), + fn () => (int) $this->fluctuations_count + ), + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/modules/DomainFluctuation/App/Models/DomainFluctuation.php b/modules/DomainFluctuation/App/Models/DomainFluctuation.php new file mode 100644 index 0000000..b648930 --- /dev/null +++ b/modules/DomainFluctuation/App/Models/DomainFluctuation.php @@ -0,0 +1,61 @@ + 'boolean', + 'status' => 'integer', + 'registeredDate' => 'integer', + 'expiredDate' => 'integer', + 'actionDate' => 'integer', + 'ianaNumber' => 'integer', + ]; + + public function term(): BelongsTo + { + return $this->belongsTo(DomainFluctuationTerm::class, 'term_id'); + } +} diff --git a/modules/DomainFluctuation/App/Models/DomainFluctuationName.php b/modules/DomainFluctuation/App/Models/DomainFluctuationName.php new file mode 100644 index 0000000..fa678c5 --- /dev/null +++ b/modules/DomainFluctuation/App/Models/DomainFluctuationName.php @@ -0,0 +1,15 @@ + 'boolean', + 'date_create' => 'integer', + ]; + + public function fluctuations(): HasMany + { + return $this->hasMany(DomainFluctuation::class, 'term_id'); + } + + public function type(): HasOne + { + return $this->hasOne(TermFluctuationType::class, 'term_fluctuation_id'); + } +} diff --git a/modules/DomainFluctuation/App/Models/TermFluctuationType.php b/modules/DomainFluctuation/App/Models/TermFluctuationType.php new file mode 100644 index 0000000..fdec23f --- /dev/null +++ b/modules/DomainFluctuation/App/Models/TermFluctuationType.php @@ -0,0 +1,29 @@ +belongsTo(DomainFluctuationTerm::class, 'term_fluctuation_id'); + } + + public function getTypeLabelAttribute(): string + { + return $this->type === 'vnnic_06' ? 'VNNIC 06' : 'VNNIC 07'; + } +} diff --git a/modules/DomainFluctuation/App/Providers/.gitkeep b/modules/DomainFluctuation/App/Providers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/modules/DomainFluctuation/App/Providers/DomainFluctuationServiceProvider.php b/modules/DomainFluctuation/App/Providers/DomainFluctuationServiceProvider.php new file mode 100644 index 0000000..3d47ffc --- /dev/null +++ b/modules/DomainFluctuation/App/Providers/DomainFluctuationServiceProvider.php @@ -0,0 +1,37 @@ +registerConfig(); + $this->registerMigrations(); + $this->app->register(RouteServiceProvider::class); + } + + public function register(): void + { + // + } + + protected function registerConfig(): void + { + $this->mergeConfigFrom( + module_path($this->moduleName, 'config/config.php'), + $this->moduleNameLower + ); + } + + protected function registerMigrations(): void + { + $this->loadMigrationsFrom(module_path($this->moduleName, 'Database/migrations')); + } +} diff --git a/modules/DomainFluctuation/App/Providers/RouteServiceProvider.php b/modules/DomainFluctuation/App/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..2c9f009 --- /dev/null +++ b/modules/DomainFluctuation/App/Providers/RouteServiceProvider.php @@ -0,0 +1,29 @@ +mapApiRoutes(); + } + + protected function mapApiRoutes(): void + { + Route::prefix('api') + ->middleware('api') + ->namespace($this->moduleNamespace) + ->group(module_path('DomainFluctuation', '/routes/api.php')); + } +} diff --git a/modules/DomainFluctuation/Database/Seeders/.gitkeep b/modules/DomainFluctuation/Database/Seeders/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/modules/DomainFluctuation/Database/migrations/2026_06_23_120000_create_domain_fluctuation_names_table.php b/modules/DomainFluctuation/Database/migrations/2026_06_23_120000_create_domain_fluctuation_names_table.php new file mode 100644 index 0000000..717b490 --- /dev/null +++ b/modules/DomainFluctuation/Database/migrations/2026_06_23_120000_create_domain_fluctuation_names_table.php @@ -0,0 +1,22 @@ +id(); + $table->string('domain_name'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('domain_fluctuation_names'); + } +}; diff --git a/modules/DomainFluctuation/Database/migrations/2026_06_23_120001_create_domain_fluctuation_terms_table.php b/modules/DomainFluctuation/Database/migrations/2026_06_23_120001_create_domain_fluctuation_terms_table.php new file mode 100644 index 0000000..eadad18 --- /dev/null +++ b/modules/DomainFluctuation/Database/migrations/2026_06_23_120001_create_domain_fluctuation_terms_table.php @@ -0,0 +1,28 @@ +id(); + $table->unsignedBigInteger('user_id'); + $table->boolean('status')->default(false); + $table->string('term_name'); + $table->string('description')->nullable(); + $table->integer('date_create'); + $table->timestamps(); + + $table->index('user_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('domain_fluctuation_terms'); + } +}; diff --git a/modules/DomainFluctuation/Database/migrations/2026_06_23_120002_create_domain_fluctuations_table.php b/modules/DomainFluctuation/Database/migrations/2026_06_23_120002_create_domain_fluctuations_table.php new file mode 100644 index 0000000..4561e4c --- /dev/null +++ b/modules/DomainFluctuation/Database/migrations/2026_06_23_120002_create_domain_fluctuations_table.php @@ -0,0 +1,55 @@ +id(); + $table->string('domainName'); + $table->unsignedBigInteger('term_id'); + $table->foreign('term_id')->references('id')->on('domain_fluctuation_terms'); + + $table->integer('registeredDate')->default(0)->index(); + $table->integer('expiredDate')->default(0)->index(); + $table->string('ownerName')->nullable(); + $table->string('ownerType')->nullable()->comment('C=ca nhan, T=to chuc'); + $table->string('address')->nullable(); + $table->string('wardName')->nullable(); + $table->string('districtName')->nullable(); + $table->string('cityName')->nullable(); + $table->string('cityCode')->nullable()->comment('Cot 7 - Ma tinh/thanh pho TT45'); + $table->string('countryCode')->nullable(); + $table->string('phone')->nullable(); + $table->string('email')->nullable(); + $table->string('fax')->nullable(); + $table->string('adminName')->nullable(); + $table->string('businessCode')->nullable()->comment('Cot 10 - Ma so DN/HKD/MST'); + $table->string('eidCode')->nullable()->comment('Cot 11 - Ma dinh danh dien tu to chuc'); + $table->string('personalId')->nullable()->comment('Cot 12 - So CCCD/ho chieu'); + $table->string('birthDate')->nullable()->comment('Cot 13 - Ngay sinh'); + $table->string('icannRegistrarName')->nullable(); + $table->string('icannRegistrarTradeName')->nullable(); + $table->boolean('isAgentManager')->nullable()->comment('true=dai ly, false=ndk'); + $table->string('action')->nullable(); + $table->string('actionReason')->nullable(); + $table->string('actionSource')->nullable(); + $table->integer('actionDate')->nullable()->default(0); + $table->string('note')->nullable(); + $table->integer('ianaNumber')->default(1710); + $table->tinyInteger('status')->default(0)->comment('0=Inactive, 1=Active'); + $table->timestamps(); + + $table->index('domainName'); + }); + } + + public function down(): void + { + Schema::dropIfExists('domain_fluctuations'); + } +}; diff --git a/modules/DomainFluctuation/Database/migrations/2026_06_23_120003_create_term_fluctuation_type_table.php b/modules/DomainFluctuation/Database/migrations/2026_06_23_120003_create_term_fluctuation_type_table.php new file mode 100644 index 0000000..2ba6ec1 --- /dev/null +++ b/modules/DomainFluctuation/Database/migrations/2026_06_23_120003_create_term_fluctuation_type_table.php @@ -0,0 +1,23 @@ +id(); + $table->bigInteger('term_fluctuation_id')->unsigned()->default(0)->index(); + $table->string('type', 50)->default('vnnic_06'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('term_fluctuation_type'); + } +}; diff --git a/modules/DomainFluctuation/composer.json b/modules/DomainFluctuation/composer.json new file mode 100644 index 0000000..bf9575e --- /dev/null +++ b/modules/DomainFluctuation/composer.json @@ -0,0 +1,31 @@ +{ + "name": "nwidart/domainfluctuation", + "description": "", + "authors": [ + { + "name": "Nicolas Widart", + "email": "n.widart@gmail.com" + } + ], + "extra": { + "laravel": { + "providers": [], + "aliases": { + + } + } + }, + "autoload": { + "psr-4": { + "Modules\\DomainFluctuation\\": "", + "Modules\\DomainFluctuation\\App\\": "App/", + "Modules\\DomainFluctuation\\Database\\Factories\\": "Database/Factories/", + "Modules\\DomainFluctuation\\Database\\Seeders\\": "Database/Seeders/" + } + }, + "autoload-dev": { + "psr-4": { + "Modules\\DomainFluctuation\\Tests\\": "tests/" + } + } +} diff --git a/modules/DomainFluctuation/config/.gitkeep b/modules/DomainFluctuation/config/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/modules/DomainFluctuation/config/config.php b/modules/DomainFluctuation/config/config.php new file mode 100644 index 0000000..9a5851a --- /dev/null +++ b/modules/DomainFluctuation/config/config.php @@ -0,0 +1,5 @@ + 'DomainFluctuation', +]; diff --git a/modules/DomainFluctuation/module.json b/modules/DomainFluctuation/module.json new file mode 100644 index 0000000..75491ba --- /dev/null +++ b/modules/DomainFluctuation/module.json @@ -0,0 +1,11 @@ +{ + "name": "DomainFluctuation", + "alias": "domainfluctuation", + "description": "Khai bao bien dong ten mien theo TT45/2025/TT-BKHCN", + "keywords": [], + "priority": 0, + "providers": [ + "Modules\\DomainFluctuation\\App\\Providers\\DomainFluctuationServiceProvider" + ], + "files": [] +} diff --git a/modules/DomainFluctuation/resources/views/.gitkeep b/modules/DomainFluctuation/resources/views/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/modules/DomainFluctuation/routes/.gitkeep b/modules/DomainFluctuation/routes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/modules/DomainFluctuation/routes/api.php b/modules/DomainFluctuation/routes/api.php new file mode 100644 index 0000000..69d454f --- /dev/null +++ b/modules/DomainFluctuation/routes/api.php @@ -0,0 +1,57 @@ +group(function () { + // Admin + Route::prefix('administrator') + ->middleware('api.auth:admin') + ->group(function () { + Route::prefix('domain-fluctuations') + ->controller(AdminDomainFluctuationController::class) + ->group(function () { + Route::get('/', 'index')->middleware('perm:domain-fluctuation.view'); + Route::post('/', 'store')->middleware('perm:domain-fluctuation.create'); + Route::get('{id}', 'show')->middleware('perm:domain-fluctuation.view'); + Route::put('{id}', 'update')->middleware('perm:domain-fluctuation.edit'); + Route::delete('{id}', 'destroy')->middleware('perm:domain-fluctuation.destroy'); + }); + + Route::prefix('domain-fluctuation-terms') + ->controller(AdminTermController::class) + ->group(function () { + Route::get('/', 'index')->middleware('perm:domain-fluctuation.view'); + Route::post('/', 'store')->middleware('perm:domain-fluctuation.create'); + Route::get('{id}', 'show')->middleware('perm:domain-fluctuation.view'); + Route::put('{id}', 'update')->middleware('perm:domain-fluctuation.edit'); + Route::delete('{id}', 'destroy')->middleware('perm:domain-fluctuation.destroy'); + }); + }); + + // Client (portal-client) + Route::prefix('client') + ->middleware('api.auth') + ->group(function () { + Route::prefix('domain-fluctuations') + ->controller(ClientDomainFluctuationController::class) + ->group(function () { + Route::get('/', 'index'); + Route::get('{id}', 'show'); + }); + }); + }); diff --git a/modules_statuses.json b/modules_statuses.json new file mode 100644 index 0000000..6663f72 --- /dev/null +++ b/modules_statuses.json @@ -0,0 +1,3 @@ +{ + "DomainFluctuation": true +} diff --git a/routes/api.php b/routes/api.php index a7ae784..441a65b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -129,10 +129,13 @@ Route::prefix('v1') Route::delete('{uuid}/destroy', 'destroy')->middleware('perm:customer.destroy'); Route::post('{uuid}/reset-password', 'resetPassword')->middleware('perm:customer.edit'); Route::post('{uuid}/send-password', 'sendPasswordEmail')->middleware('perm:customer.edit'); + Route::post('{uuid}/login-as', 'loginAs')->middleware('perm:customer.edit'); Route::post('{uuid}/add-credit', 'addCredit')->middleware('perm:customer.edit'); Route::post('{uuid}/add-total', 'addTotal')->middleware('perm:customer.edit'); Route::get('{uuid}/services', 'getServices')->middleware('perm:customer.view'); Route::post('{uuid}/services', 'createService')->middleware('perm:customer.edit'); + Route::post('{uuid}/services/bulk-delete', 'bulkDeleteServices')->middleware('perm:customer.edit'); + Route::put('{uuid}/services/{serviceId}', 'updateService')->middleware('perm:customer.edit'); Route::get('{uuid}/orders', 'getOrders')->middleware('perm:customer.view'); Route::get('{uuid}/invoices', 'getInvoices')->middleware('perm:customer.view'); Route::get('{uuid}/transactions', 'getTransactions')->middleware('perm:customer.view'); diff --git a/routes/client.php b/routes/client.php index 4c55a95..6743d8a 100644 --- a/routes/client.php +++ b/routes/client.php @@ -31,6 +31,7 @@ Route::prefix('v1') Route::post('register', 'register')->name('register'); Route::post('refresh', 'refresh')->name('refresh'); Route::post('forgot-password', 'forgotPassword')->name('forgot-password'); + Route::post('impersonate/exchange', 'impersonateExchange')->name('impersonate.exchange'); Route::group(['middleware' => ['api.auth']], function () { Route::get('me', 'me')->name('me');