diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index bfb5955..dacb821 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -3,6 +3,8 @@ namespace App\Exceptions; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; +use Illuminate\Database\QueryException; +use Illuminate\Database\UniqueConstraintViolationException; use Throwable; class Handler extends ExceptionHandler @@ -34,6 +36,18 @@ class Handler extends ExceptionHandler */ public function register() { + // Duplicate constraint + $this->renderable(function (QueryException $e, $request) { + + if ($e->errorInfo[1] == 1062) { + return response()->json([ + 'error' => true, + 'message' => 'Dữ liệu đã tồn tại' + ], 422); + } + }); + + $this->renderable(function (\Symfony\Component\HttpKernel\Exception\NotFoundHttpException $e, $request) { if (!$request->expectsJson() && !$request->is('v1/*') && !$request->is('api/*')) { return null; diff --git a/app/Http/Controllers/Api/Admin/Product/ProductCategoryController.php b/app/Http/Controllers/Api/Admin/Product/ProductCategoryController.php index 5682a22..d25dec0 100644 --- a/app/Http/Controllers/Api/Admin/Product/ProductCategoryController.php +++ b/app/Http/Controllers/Api/Admin/Product/ProductCategoryController.php @@ -78,10 +78,10 @@ class ProductCategoryController extends Controller $validated['slug'] = Str::slug($validated['name']); - // Ensure unique slug + // Ensure unique slug (include soft-deleted to avoid DB unique constraint violation) $originalSlug = $validated['slug']; $count = 1; - while (ProductCategories::where('slug', $validated['slug'])->exists()) { + while (ProductCategories::withTrashed()->where('slug', $validated['slug'])->exists()) { $validated['slug'] = "{$originalSlug}-{$count}"; $count++; } @@ -115,7 +115,7 @@ class ProductCategoryController extends Controller $slug = Str::slug($validated['name']); $originalSlug = $slug; $count = 1; - while (ProductCategories::where('slug', $slug)->where('id', '!=', $id)->exists()) { + while (ProductCategories::withTrashed()->where('slug', $slug)->where('id', '!=', $id)->exists()) { $slug = "{$originalSlug}-{$count}"; $count++; } @@ -153,4 +153,118 @@ class ProductCategoryController extends Controller 'message' => 'Xoá nhóm sản phẩm thành công.', ]); } + + public function trashed(): JsonResponse + { + $items = ProductCategories::onlyTrashed() + ->withCount('products') + ->orderBy('deleted_at', 'desc') + ->get(); + + return response()->json([ + 'error' => false, + 'data' => ProductCategoryResource::collection($items), + ]); + } + + public function restore(int $id): JsonResponse + { + $category = ProductCategories::onlyTrashed()->find($id); + + if (!$category) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy nhóm trong thùng rác.'], 404); + } + + $category->restore(); + + return response()->json([ + 'error' => false, + 'message' => 'Khôi phục nhóm sản phẩm thành công.', + ]); + } + + public function forceDelete(int $id): JsonResponse + { + $category = ProductCategories::onlyTrashed()->find($id); + + if (!$category) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy nhóm trong thùng rác.'], 404); + } + + $category->forceDelete(); + + return response()->json([ + 'error' => false, + 'message' => 'Xoá vĩnh viễn nhóm sản phẩm thành công.', + ]); + } + + public function bulkUpdateStatus(Request $request): JsonResponse + { + $validated = $request->validate([ + 'ids' => 'required|array', + 'ids.*' => 'integer', + 'is_active' => 'required|boolean', + ]); + + $updated = ProductCategories::whereIn('id', $validated['ids']) + ->update(['is_active' => $validated['is_active']]); + + $label = $validated['is_active'] ? 'kích hoạt' : 'ẩn'; + + return response()->json([ + 'error' => false, + 'message' => "Đã {$label} {$updated} nhóm sản phẩm.", + ]); + } + + public function bulkDestroy(Request $request): JsonResponse + { + $ids = $request->validate(['ids' => 'required|array', 'ids.*' => 'integer'])['ids']; + + $blocked = ProductCategories::withCount('products') + ->whereIn('id', $ids) + ->get() + ->filter(fn ($c) => $c->products_count > 0); + + if ($blocked->isNotEmpty()) { + $names = $blocked->pluck('name')->implode(', '); + return response()->json([ + 'error' => true, + 'message' => "Không thể xoá các nhóm đang chứa sản phẩm: {$names}.", + ], 422); + } + + $deleted = ProductCategories::whereIn('id', $ids)->delete(); + + return response()->json([ + 'error' => false, + 'message' => "Đã xoá {$deleted} nhóm sản phẩm.", + ]); + } + + public function bulkRestore(Request $request): JsonResponse + { + $ids = $request->validate(['ids' => 'required|array', 'ids.*' => 'integer'])['ids']; + + $restored = ProductCategories::onlyTrashed()->whereIn('id', $ids)->restore(); + + return response()->json([ + 'error' => false, + 'message' => "Đã khôi phục {$restored} nhóm sản phẩm.", + ]); + } + + public function bulkForceDelete(Request $request): JsonResponse + { + $ids = $request->validate(['ids' => 'required|array', 'ids.*' => 'integer'])['ids']; + + $count = ProductCategories::onlyTrashed()->whereIn('id', $ids)->count(); + ProductCategories::onlyTrashed()->whereIn('id', $ids)->forceDelete(); + + return response()->json([ + 'error' => false, + 'message' => "Đã xoá vĩnh viễn {$count} nhóm sản phẩm.", + ]); + } } diff --git a/app/Http/Controllers/Api/Client/Service/ServiceController.php b/app/Http/Controllers/Api/Client/Service/ServiceController.php new file mode 100644 index 0000000..bf339de --- /dev/null +++ b/app/Http/Controllers/Api/Client/Service/ServiceController.php @@ -0,0 +1,49 @@ +serviceClientService = $serviceClientService; + $this->serviceClientRepository = $serviceClientRepository; + } + + public function getService(Request $request) + { + $limit = $request->get('limit', 10); + $services = $this->serviceClientService->getActiveServices($limit); + + return ServiceClientResource::collection($services)->additional([ + 'error' => false, + 'message' => 'Lấy dữ liệu thành công', + 'page_type' => 'service', + ]); + } + + + public function getDetail(Request $request, string $uuid) + { + $service = $this->serviceClientService->getDetailService($uuid); + + return (new ServiceClientDetailResource($service))->additional([ + 'error' => false, + 'message' => 'Lấy dữ liệu thành công', + 'page_type' => 'service', + ]); + } + +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/Service/OrderController.php b/app/Http/Controllers/Api/Service/OrderController.php index 7cfebac..fa07308 100644 --- a/app/Http/Controllers/Api/Service/OrderController.php +++ b/app/Http/Controllers/Api/Service/OrderController.php @@ -345,6 +345,74 @@ class OrderController extends Controller } } + /** + * GET /v1/client/dich-vu/{uuid} — service detail by UUID + */ + public function getClientServiceDetail(Request $request, string $uuid): JsonResponse + { + try { + $service = Service::query() + ->with(['product', 'addons', 'invoice', 'orderItem']) + ->where('uuid', $uuid) + ->where('customer_id', $request->user()->id) + ->first(); + + if (!$service) { + return $this->notFound('Không tìm thấy dịch vụ.'); + } + + $vmware = $service->vm_id + ? ModuleVmware::with(['ipAddress', 'os', 'vCenter'])->find((int) $service->vm_id) + : null; + + $data = [ + 'id' => $service->id, + 'uuid' => $service->uuid, + 'service_name' => $service->product?->name ?? $service->server_name, + 'server_name' => $service->server_name, + 'product_type' => $service->product?->product_type, + 'billing_cycle' => $service->orderItem?->billing_cycle, + 'status' => $service->status, + 'status_text' => $service->status_label, + 'auto_renew' => (bool) $service->auto_renew, + 'started_at' => $this->isoDate($service->started_at), + 'expires_at' => $this->isoDate($service->expires_at), + 'note' => $service->note, + 'invoice_id' => $service->invoice_id, + 'invoice_number' => $service->invoice?->invoice_number, + 'product' => $service->product ? [ + 'id' => $service->product->id, + 'uuid' => $service->product->uuid, + 'name' => $service->product->name, + 'product_type' => $service->product->product_type, + 'specs' => [ + 'cpu' => $service->product->cpu, + 'ram' => $service->product->ram, + 'disk' => $service->product->disk, + 'bandwidth' => $service->product->bandwidth, + ], + ] : null, + 'addons' => $service->addons->map(fn($a) => [ + 'type' => $a->addon_type, + 'value' => $a->addon_value, + 'price' => $a->price, + ])->all(), + 'vmware' => $vmware ? [ + 'id' => $vmware->id, + 'name' => $vmware->name, + 'hostname' => $vmware->hostname, + 'ip_address' => $vmware->ipAddress?->ip, + 'os_name' => $vmware->os?->name, + 'status' => $vmware->status, + ] : null, + ]; + + return response()->json(['success' => true, 'data' => $data]); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + /** * PATCH /v1/client/dich-vu/{id}/auto-renew — toggle auto-renewal on a service */ diff --git a/app/Http/Controllers/Api/Service/ServiceController.php b/app/Http/Controllers/Api/Service/ServiceController.php index ade4644..02b924f 100644 --- a/app/Http/Controllers/Api/Service/ServiceController.php +++ b/app/Http/Controllers/Api/Service/ServiceController.php @@ -16,6 +16,7 @@ use App\Models\OrderAddon; use App\Models\OrderItem; use App\Models\Product; use App\Models\ProductPricings; +use App\Models\Service; use App\Repositories\Api\Service\ServiceRepository; use App\Services\Api\Service\ServiceService; use Illuminate\Http\Request; @@ -190,6 +191,31 @@ class ServiceController extends Controller }; } + public function getDetailServiceCustomer(Request $request, $uuid) + { + $service = Service::where('uuid', $uuid)->first(); + + if (!$service) { + return response()->json([ + 'error' => true, + 'message' => 'Dịch vụ không tồn tại' + ], 404); + } + + $service->load([ + 'product.category', + 'orderItem.os.osGroup', + 'orderItem.pricing', + 'invoice', + ]); + + return response()->json([ + 'error' => false, + 'message' => 'Lấy dữ liệu thành công', + 'data' => new ServiceResource($service), + ]); + } + /** * Get addons for a specific product * GET /v1/products/{productId}/addons diff --git a/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php b/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php new file mode 100644 index 0000000..00c08d7 --- /dev/null +++ b/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php @@ -0,0 +1,34 @@ + + */ + public function toArray(Request $request): array + { + if (!$this->resource) { + return []; + } + + return [ + 'uuid' => $this->uuid, + 'server_name' => $this->server_name, + 'status' => $this->status, + 'auto_renew' => $this->auto_renew, + 'started_at' => $this->started_at?->toISOString(), + 'expires_at' => $this->expires_at?->toISOString(), + 'os' => [ + 'version' => $this->orderItem?->os?->version, + 'group' => $this->orderItem?->os?->os_group?->name, + ], + ]; + } +} diff --git a/app/Http/Resources/Api/Client/Service/ServiceClientResource.php b/app/Http/Resources/Api/Client/Service/ServiceClientResource.php new file mode 100644 index 0000000..9bccd88 --- /dev/null +++ b/app/Http/Resources/Api/Client/Service/ServiceClientResource.php @@ -0,0 +1,30 @@ + + */ + public function toArray(Request $request): array + { + if (!$this->resource) { + return []; + } + + return [ + 'uuid' => $this->uuid, + 'vm_id' => $this->vm_id, + 'status' => $this->status, + 'auto_renew' => $this->auto_renew, + 'started_at' => $this->started_at?->toISOString(), + 'expires_at' => $this->expires_at?->toISOString(), + ]; + } +} diff --git a/app/Models/Order.php b/app/Models/Order.php index 14c20c3..65c0728 100644 --- a/app/Models/Order.php +++ b/app/Models/Order.php @@ -65,4 +65,9 @@ class Order extends Model { return $this->hasMany(ModuleVmware::class, 'order_id'); } + + public function vmWare() + { + return $this->hasMany(ModuleVmware::class, 'order_id'); + } } diff --git a/app/Models/Service.php b/app/Models/Service.php index 7e7ab61..6edc0ee 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\View\Components\Module\OsVersion; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; diff --git a/app/Providers/RepositoryProvider.php b/app/Providers/RepositoryProvider.php index 9e97f99..2f37131 100644 --- a/app/Providers/RepositoryProvider.php +++ b/app/Providers/RepositoryProvider.php @@ -385,6 +385,12 @@ class RepositoryProvider extends ServiceProvider \App\Repositories\Api\Service\ServiceRepository::class ); + // Service Client API + $this->app->bind( + \App\Repositories\Api\ServiceClient\ServiceClientRepositoryInterface::class, + \App\Repositories\Api\ServiceClient\ServiceClientRepository::class + ); + // DNS Manager $this->app->bind(DnsManagerServerRepositoryInterface::class, DnsManagerServerRepository::class); $this->app->bind(DnsDomainRepositoryInterface::class, DnsDomainRepository::class); diff --git a/app/Repositories/Api/ServiceClient/ServiceClientRepository.php b/app/Repositories/Api/ServiceClient/ServiceClientRepository.php new file mode 100644 index 0000000..19d2173 --- /dev/null +++ b/app/Repositories/Api/ServiceClient/ServiceClientRepository.php @@ -0,0 +1,54 @@ +model + ->with(['orderItem' => fn($q) => $q->with('product')]) + ->where('status', 'active') + ->orderBy('started_at', 'asc') + ->paginate($limit); + } + + public function findActiveBySlug(string $slug) + { + return $this->model + ->with(['osVersions.osGroup', 'category', 'pricings' => fn ($q) => $q->where('is_active', 1)]) + ->where('slug', $slug) + ->where('is_active', 1) + ->first(); + } + + public function getPricing(int $productId, string $billingCycle) + { + $pricing = $this->model->find($productId) + ->pricings() + ->where('billing_cycle', $billingCycle) + ->where('is_active', 1) + ->first(); + + return $pricing ? $pricing->price : null; + } + + public function getDetailService(string $uuid) + { + return $this->model + ->with(['orderItem.os.osGroup.host.datacenter.vCenter']) + ->where('uuid', $uuid) + ->first(); + } +} diff --git a/app/Repositories/Api/ServiceClient/ServiceClientRepositoryInterface.php b/app/Repositories/Api/ServiceClient/ServiceClientRepositoryInterface.php new file mode 100644 index 0000000..a1cab43 --- /dev/null +++ b/app/Repositories/Api/ServiceClient/ServiceClientRepositoryInterface.php @@ -0,0 +1,19 @@ +serviceClientRepository = $serviceClientRepository; + } + + /** + * Lấy danh sách dịch vụ đang hoạt động + */ + public function getActiveServices(int $limit = 10) + { + return $this->serviceClientRepository->getActiveServices($limit); + } + + public function findActiveBySlug(string $slug) + { + return $this->serviceClientRepository->findActiveBySlug($slug); + } + + public function getDetailService(string $uuid) + { + return $this->serviceClientRepository->getDetailService($uuid); + } +} diff --git a/database/migrations/2026_03_11_200005_create_services_table.php b/database/migrations/2026_03_11_200005_create_services_table.php index dd3f8b0..d02abe2 100644 --- a/database/migrations/2026_03_11_200005_create_services_table.php +++ b/database/migrations/2026_03_11_200005_create_services_table.php @@ -10,6 +10,7 @@ return new class extends Migration { Schema::create('services', function (Blueprint $table) { $table->id(); + $table->uuid()->unique(); $table->unsignedBigInteger('customer_id')->index(); $table->unsignedBigInteger('product_id')->nullable()->index(); diff --git a/routes/api.php b/routes/api.php index 8f7f62e..edab06b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -117,9 +117,16 @@ Route::prefix('v1') ->group(function () { Route::get('/', 'index')->middleware('perm:product-service.view')->name('index'); Route::post('/', 'store')->middleware('perm:product-service.create')->name('store'); + Route::get('trashed', 'trashed')->middleware('perm:product-service.view')->name('trashed'); + Route::post('bulk-update-status', 'bulkUpdateStatus')->middleware('perm:product-service.edit')->name('bulk-update-status'); + Route::post('bulk-destroy', 'bulkDestroy')->middleware('perm:product-service.destroy')->name('bulk-destroy'); + Route::post('bulk-restore', 'bulkRestore')->middleware('perm:product-service.edit')->name('bulk-restore'); + Route::post('bulk-force-delete', 'bulkForceDelete')->middleware('perm:product-service.destroy')->name('bulk-force-delete'); Route::get('{id}', 'show')->middleware('perm:product-service.view')->name('show'); Route::put('{id}', 'update')->middleware('perm:product-service.edit')->name('update'); Route::delete('{id}', 'destroy')->middleware('perm:product-service.destroy')->name('destroy'); + Route::post('{id}/restore', 'restore')->middleware('perm:product-service.edit')->name('restore'); + Route::delete('{id}/force', 'forceDelete')->middleware('perm:product-service.destroy')->name('force-delete'); }); // Orders (module: order) diff --git a/routes/client.php b/routes/client.php index 1694f38..c34dddd 100644 --- a/routes/client.php +++ b/routes/client.php @@ -5,6 +5,7 @@ use App\Http\Controllers\Api\Client\ClientController; use App\Http\Controllers\Api\Service\OrderController; use App\Http\Controllers\Api\Service\ProductCategoryController; use App\Http\Controllers\Api\Service\ServiceController; +use App\Http\Controllers\Api\Client\Service\ServiceController as ServiceClientController; use App\Http\Controllers\Api\Transaction\TransactionController; use Illuminate\Support\Facades\Route; @@ -34,6 +35,18 @@ Route::prefix('v1')->as('api.')->group(function () { Route::patch('dich-vu/{id}/auto-renew', 'updateClientServiceAutoRenew')->name('services.autoRenew.update'); }); + + Route::middleware(['api.auth'])->prefix('client-service')->controller(ServiceClientController::class)->as('client-service.')->group(function () { + Route::get('/', 'getService')->name('list'); + Route::get('{uuid}', 'getDetail')->name('detail'); + }); + + + Route::middleware(['api.auth'])->prefix('service')->controller(ServiceController::class)->as('service.')->group(function () { + Route::get('/', 'getService')->name('list'); + Route::get('{uuid}', 'getDetailServiceCustomer')->name('detail'); + }); + Route::middleware(['api.auth'])->controller(TransactionController::class)->group(function () { Route::get('payment-history', 'getClientPaymentHistory')->name('payment-history.list'); }); @@ -103,6 +116,8 @@ Route::prefix('v1')->as('api.')->group(function () { }); }); + + Route::middleware(['api.auth'])->prefix('orders')->as('orders.')->group(function () { Route::controller(OrderController::class)->group(function () { Route::get('/', 'getList')->name('list');