json(['error' => false, 'data' => $this->serviceProvisioning->getServices($uuid)]); } public function attachService(string $uuid, Request $request): JsonResponse { $data = $request->validate(['service_id' => 'required|integer|exists:services,id']); $service = $this->serviceProvisioning->attachService($uuid, $data['service_id']); return response()->json(['error' => false, 'message' => 'Đã gán dịch vụ cho khách hàng', 'data' => $service]); } public function bulkDeleteServices(string $uuid, Request $request): JsonResponse { $data = $request->validate(['ids' => 'required|array|min:1', 'ids.*' => 'integer']); $deleted = $this->serviceProvisioning->bulkDeleteServices($uuid, $data['ids']); return response()->json(['error' => false, 'message' => "Đã gỡ {$deleted} dịch vụ khỏi khách hàng", 'data' => ['deleted' => $deleted]]); } public function updateService(string $uuid, int $serviceId, Request $request): JsonResponse { $data = $request->validate([ 'server_name' => 'nullable|string|max:255', 'product_id' => 'nullable|integer|exists:products,id', 'status' => 'nullable|in:pending,active,suspended,expired,cancelled,terminated', 'auto_renew' => 'nullable|boolean', 'started_at' => 'nullable|date', 'expires_at' => 'nullable|date', 'note' => 'nullable|string', 'requirements' => 'nullable|string', 'feedback' => 'nullable|string', 'price' => 'nullable|numeric|min:0', 'billing_cycle' => ['nullable', Rule::in(BillingCycleCatalog::codes())], 'virtual_machine_id' => 'nullable|integer|exists:virtual_machines,id', 'os_id' => 'nullable|integer|exists:os,id', 'vm_cpu' => 'nullable|integer|min:1', 'vm_ram_mb' => 'nullable|integer|min:1', 'vm_disk_gb' => 'nullable|integer|min:1', 'vm_ip' => 'nullable|string|max:45', 'vm_dns' => 'nullable|string|max:100', 'vm_user' => 'nullable|string|max:255', 'vm_password' => 'nullable|string|max:255', 'vm_port' => 'nullable|integer|min:1|max:65535', ]); $service = $this->serviceProvisioning->updateService($uuid, $serviceId, $data); return response()->json(['error' => false, 'message' => 'Cập nhật dịch vụ thành công', 'data' => $service]); } public function changeServiceIp(string $uuid, int $serviceId, Request $request): JsonResponse { $data = $request->validate(['ip' => 'required|ip']); abort_if(!Service::where('id', $serviceId)->value('vm_id'), 422, 'Dịch vụ chưa gắn VM.'); $service = $this->serviceProvisioning->updateService($uuid, $serviceId, ['vm_ip' => $data['ip']]); return response()->json(['error' => false, 'message' => 'Đã đổi IP thành công', 'data' => $service]); } public function changeServicePort(string $uuid, int $serviceId, Request $request): JsonResponse { $data = $request->validate(['port' => 'required|integer|min:1|max:65535']); abort_if(!Service::where('id', $serviceId)->value('vm_id'), 422, 'Dịch vụ chưa gắn VM.'); $service = $this->serviceProvisioning->updateService($uuid, $serviceId, ['vm_port' => $data['port']]); return response()->json(['error' => false, 'message' => 'Đã đổi port thành công', 'data' => $service]); } public function notifyServiceRenewal( string $uuid, int $serviceId, ServiceRenewalReminderService $reminders, ): JsonResponse { $customer = $this->resolveCustomer($uuid); $service = Service::query() ->where('customer_id', $customer->id) ->where('id', $serviceId) ->first(); abort_if(! $service, 404, 'Không tìm thấy dịch vụ'); abort_if(! $customer->email, 422, 'Khách hàng chưa có email'); abort_if(! $service->expires_at, 422, 'Dịch vụ chưa có ngày hết hạn'); $reminders->send([$service->id], Auth::guard('admin')->user()); return response()->json([ 'error' => false, 'message' => 'Đã đưa email nhắc gia hạn vào hàng đợi gửi và ghi nhận lịch sử.', ]); } public function serviceRenewals(string $uuid, int $serviceId): JsonResponse { $service = Service::where('customer_id', $this->resolveCustomer($uuid)->id)->findOrFail($serviceId); $renewals = ActivityLog::where('subject_type', Service::class) ->where('subject_id', $service->id) ->where('event', 'renewed') ->latest('id') ->limit(100) ->get(['id', 'causer_type', 'causer_name', 'old_values', 'new_values', 'created_at']); return response()->json(['error' => false, 'data' => $renewals]); } public function serviceProvisioningLogs(string $uuid, int $serviceId): JsonResponse { $service = Service::where('customer_id', $this->resolveCustomer($uuid)->id)->findOrFail($serviceId); $logs = ActivityLog::where('subject_type', Service::class) ->where('subject_id', $service->id) ->whereIn('event', ServiceProvisionLog::EVENTS) ->orderBy('id') ->get(['id', 'event', 'description', 'causer_type', 'causer_name', 'new_values', 'created_at']); return response()->json(['error' => false, 'data' => $logs]); } public function renewService(string $uuid, int $serviceId, Request $request): JsonResponse { $data = $request->validate([ 'months' => 'required|integer|min:1|max:120', 'payment' => 'required|in:balance,manual', 'note' => 'nullable|string|max:500', 'proof' => ['nullable', ...array_slice(explode('|', InvoiceAttachment::UPLOAD_RULE), 1)], ]); $customer = $this->resolveCustomer($uuid); $current = Service::where('customer_id', $customer->id)->find($serviceId); $previousExpiresAt = $current?->expires_at; if ($current && $violation = RenewalPeriodGuard::violation($current, (int) $data['months'])) { return response()->json(['error' => true, 'message' => $violation], 422); } $result = app(OrderQueryService::class)->renewServiceByAdmin( $serviceId, $customer, (int) $data['months'], $data['payment'], (int) auth()->id(), (string) ($data['note'] ?? ''), ); if (! $result['ok']) { return response()->json( ['error' => true, 'message' => $result['message'], 'data' => $result['data'] ?? null], $result['code'], ); } $this->notifyRenewal($serviceId, (int) $result['data']['invoice_id'], (int) $data['months'], $previousExpiresAt, $data['payment']); if ($data['payment'] === 'manual' && $request->hasFile('proof')) { $this->storeRenewalProof($request->file('proof'), (int) $result['data']['invoice_id']); } return response()->json(['error' => false, 'message' => $result['message'], 'data' => $result['data']]); } private function notifyRenewal(int $serviceId, int $invoiceId, int $months, ?\Carbon\CarbonInterface $previousExpiresAt, string $payment): void { $admin = auth()->user(); app(ServiceRenewalNotifier::class)->notifyAdminRenewal( Service::with(['customer', 'product'])->findOrFail($serviceId), Invoice::findOrFail($invoiceId), $months, $previousExpiresAt, $payment, (string) ($admin?->fullname ?? $admin?->username ?? 'Admin'), $admin?->id, ); } private function storeRenewalProof(UploadedFile $file, int $invoiceId): void { InvoiceAttachment::create([ 'invoice_id' => $invoiceId, 'type' => InvoiceAttachment::TYPE_PAYMENT_PROOF, 'filename' => $file->getClientOriginalName(), 'path' => $file->store("invoice-attachments/{$invoiceId}", 'public'), 'disk' => 'public', 'mime_type' => $file->getMimeType(), 'size' => $file->getSize(), 'uploaded_by' => auth()->user()?->username ?? 'admin', ]); } public function createService(string $uuid, Request $request): JsonResponse { $data = $request->validate([ 'product_id' => 'required_without:custom|nullable|integer|exists:products,id', 'custom' => 'nullable|array', 'custom.name' => 'required_with:custom|string|max:255', 'custom.category' => ['required_with:custom', Rule::in(CustomProduct::CATEGORIES)], 'custom.unit_price' => 'required_with:custom|numeric|min:0', 'custom.cpu' => 'nullable|integer|min:1', 'custom.ram_mb' => 'nullable|integer|min:1', 'custom.disk_gb' => 'nullable|integer|min:1', 'custom.bandwidth_gb' => 'nullable|integer|min:0', 'custom.description' => 'nullable|string|max:5000', 'custom.specs' => 'nullable|array|max:20', 'custom.specs.*.label' => 'required|string|max:100', 'custom.specs.*.value' => 'required|string|max:255', 'virtual_machine_id' => 'nullable|integer|exists:virtual_machines,id', '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', 'billing_cycle' => ['nullable', Rule::in(BillingCycleCatalog::codes())], 'price' => 'nullable|numeric|min:0', 'payment_status' => 'nullable|in:paid,partial,unpaid', 'payment_method' => 'required_unless:payment_status,unpaid|nullable|in:transfer,balance', 'amount_paid' => 'required_if:payment_status,partial|nullable|numeric|gt:0', 'proof_images' => 'nullable|array', 'proof_images.*' => 'file|image|max:5120', 'os_id' => 'nullable|integer|exists:os,id', 'vm_cpu' => 'nullable|integer|min:1', 'vm_ram_mb' => 'nullable|integer|min:1', 'vm_disk_gb' => 'nullable|integer|min:1', 'vm_ip' => 'nullable|string|max:45', 'vm_dns' => 'nullable|string|max:100', 'vm_user' => 'nullable|string|max:255', 'vm_password' => 'nullable|string|max:255', 'vm_port' => 'nullable|integer|min:1|max:65535', ]); $data['payment_status'] ??= 'paid'; if ($data['payment_status'] === 'unpaid') { $data['payment_method'] = null; } if (($data['payment_method'] ?? null) === 'transfer' && $request->hasFile('proof_images')) { $data['_proof_paths'] = collect($request->file('proof_images')) ->map(fn($file) => $file->store('payment-proofs', 'public')) ->all(); } $service = $this->serviceProvisioning->createService($uuid, $data, $request->ip()); return response()->json(['error' => false, 'message' => 'Service created', 'data' => $service], 201); } public function listAvailableVms(Request $request): JsonResponse { $result = $this->serviceProvisioning->listAvailableVms( trim((string) $request->get('q', '')), max(1, (int) $request->get('page', 1)), max(1, min((int) $request->get('per_page', 200), 200)), ); return response()->json(['error' => false] + $result); } }