with(self::LIST_RELATIONS); if ($scope === 'trash') { $query->onlyTrashed(); } else { $query->where('status', $scope === 'orders' ? 'order' : $scope); } return $query ->when(! $actor->isMasterAdmin(), fn ($q) => $q->where('tenant_id', $tenantId)) ->when($actor->isMasterAdmin() && $tenantId, fn ($q) => $q->where('tenant_id', $tenantId)) ->when(! empty($filters['source']), fn ($q) => $q->where('source', $filters['source'])) ->when(! empty($filters['search']), fn ($q) => $q->where(function ($search) use ($filters) { $term = "%{$filters['search']}%"; $search->where('customer_code', 'ILIKE', $term) ->orWhere('name', 'ILIKE', $term) ->orWhere('email', 'ILIKE', $term) ->orWhere('whatsapp_number', 'ILIKE', $term) ->orWhere('external_username', 'ILIKE', $term); })) ->orderBy($filters['sort_by'] ?? 'id', $filters['sort_direction'] ?? 'desc') ->paginate($filters['per_page'] ?? 10); } public function find(int $id, bool $trashed = false): Customer { return Customer::query() ->when($trashed, fn ($q) => $q->withTrashed()) ->with(self::DETAIL_RELATIONS) ->findOrFail($id); } public function create(array $data, User $actor, ?int $tenantId): Customer { $images = $data['images'] ?? []; unset($data['images']); $data['tenant_id'] = $actor->isMasterAdmin() ? ($data['tenant_id'] ?? $tenantId) : $tenantId; if (! $data['tenant_id']) { throw ValidationException::withMessages(['tenant_id' => 'Tenant wajib dipilih.']); } unset($data['customer_code'], $data['external_username'], $data['order_stage']); $data['customer_code'] = null; $data['source'] ??= 'manual'; $data['status'] = $data['source'] === 'manual' ? 'order' : 'unmanaged'; $data['order_stage'] ??= 'registration'; $this->validateTenantRelations($data); $this->validateWilayah($data); $customer = DB::transaction(function () use ($data,$images,$actor) { $customer = Customer::create($data); if (isset($data['topology_id'], $data['topology_port_number'])) { $this->syncTopologyReservation($customer, (int) $data['topology_id'], (int) $data['topology_port_number']); } $this->syncImages($customer, $images, $actor); $this->syncPrimaryContacts($customer); return $customer; }); $customer->load('tenant:id,tenant_name'); $this->createInstallationTicketWhenRequired($customer, $actor); $this->notifications->emit( 'customer.registration', $customer, $customer, ['customer' => $customer->toArray(), 'tenant' => $customer->tenant?->toArray() ?? []], 'registration', ['action_url' => "/customers/orders/{$customer->id}/detail"], ); User::query() ->whereHas('userTenants', fn ($query) => $query ->where('tenant_id', $customer->tenant_id) ->where('role', 'tenant_owner')) ->each(fn (User $owner) => $this->notifications->emit( 'customer.registration', $owner, $customer, ['customer' => $customer->toArray(), 'tenant' => $customer->tenant?->toArray() ?? []], 'registration', ['action_url' => "/customers/orders/{$customer->id}/detail", 'icon' => 'cil-user-follow'], )); return $customer->load(self::DETAIL_RELATIONS); } public function update(Customer $customer, array $data, User $actor): Customer { $hasImages = array_key_exists('images', $data); $images = $data['images'] ?? []; unset($data['images']); if (! $actor->isMasterAdmin()) { unset($data['tenant_id']); } $data['tenant_id'] ??= $customer->tenant_id; $this->validateTenantRelations($data); $this->validateWilayah($data); unset($data['status']); unset($data['customer_code'], $data['external_username'], $data['order_stage']); DB::transaction(function () use ($customer,$data) { $customer->update($data); if (isset($data['topology_id'],$data['topology_port_number'])) $this->syncTopologyReservation($customer,(int)$data['topology_id'],(int)$data['topology_port_number']); }); $this->syncPrimaryContacts($customer); if ($hasImages) { $this->syncImages($customer, $images, $actor); } return $customer->fresh()->load(self::DETAIL_RELATIONS); } public function activate(Customer $customer, array $data, User $actor): Customer { $source = $data['activation_source']; unset($data['activation_source']); $mode = \App\Models\TenantInstallationSetting::firstOrCreate( ['tenant_id' => $customer->tenant_id], ['activation_mode' => 'list_order'] )->activation_mode; if ($source !== $mode) { throw ValidationException::withMessages([ 'activation_source' => $mode === 'ticket' ? 'Tenant mengatur aktivasi customer hanya melalui Ticket.' : 'Tenant mengatur aktivasi customer hanya melalui List Order.', ]); } $customer = $this->ensureActivationCode($customer); $data['tenant_id'] = $customer->tenant_id; $this->validateTenantRelations($data); if (! $customer->desa_id || ! $customer->installation_address) { throw ValidationException::withMessages([ 'installation_address' => 'Alamat pemasangan dan wilayah desa wajib lengkap sebelum aktivasi.', ]); } $this->routerOsProvisioning->create($customer, $data); unset($data['external_password']); DB::transaction(function () use ($customer,$data) { DB::statement('SELECT pg_advisory_xact_lock(?)', [(int)$customer->tenant_id]); $customer->update([ ...$data, 'customer_code' => $customer->customer_code, 'status' => 'active', 'order_stage' => 'ready_activation', 'activated_at' => now(), 'inactivated_at' => null, ]); $connection=$customer->topologyConnections()->where('status','reserved')->latest('id')->first(); if($connection){$connection->update(['status'=>'active','connected_at'=>now()]);$connection->odpPort()->update(['status'=>'used']);} }); $customer->load('tenant:id,tenant_name'); $this->notifications->emit( 'customer.activation', $customer, $customer, ['customer' => $customer->toArray(), 'tenant' => $customer->tenant?->toArray() ?? []], 'activation-'.$customer->activated_at->format('YmdHis'), ['action_url' => "/customers/active/{$customer->id}/detail"], ); return $customer->fresh()->load(self::DETAIL_RELATIONS); } public function deactivate(Customer $customer): Customer { if ($customer->nasMikrotik?->connection_type === 'api') { $this->routerOsProvisioning->setEnabled($customer, false); } $customer->update(['status' => 'inactive', 'inactivated_at' => now()]); return $customer->fresh()->load(self::DETAIL_RELATIONS); } public function trash(Customer $customer): void { $this->releaseTopologyPort($customer); $customer->delete(); } public function restore(Customer $customer): Customer { $customer->restore(); return $customer->fresh()->load(self::DETAIL_RELATIONS); } public function forceDelete(Customer $customer): void { if ($customer->status !== 'order' && $customer->nasAccount()->exists() && $customer->nasMikrotik?->connection_type === 'api') { $this->routerOsProvisioning->delete($customer); } $this->releaseTopologyPort($customer); $customer->forceDelete(); } public function options(User $actor, ?int $tenantId): array { $scope = fn ($query) => $query ->when(! $actor->isMasterAdmin(), fn ($q) => $q->where('tenant_id', $tenantId)) ->when($actor->isMasterAdmin() && $tenantId, fn ($q) => $q->where('tenant_id', $tenantId)) ->where('status', 'active') ->orderBy('name'); return [ 'package_profiles' => $scope(NasPackageProfile::query()) ->get(['id', 'tenant_id', 'name', 'service_type', 'download_kbps', 'upload_kbps', 'price']), 'nas_mikrotiks' => $scope(NasMikrotik::query()) ->get(['id', 'tenant_id', 'name', 'connection_type', 'host']), 'billing_profiles' => $scope(BillingProfile::query()) ->get(['id', 'tenant_id', 'name', 'schedule_type', 'invoice_day', 'send_day', 'warning_day', 'isolation_day', 'invoice_days_before', 'send_days_before', 'warning_days_before', 'isolation_days_after', 'tax_type', 'tax_rate']), 'topology_odps' => $scope(TopologyNode::query()->whereHas('deviceType', fn($q)=>$q->whereRaw('UPPER(code) = ?', ['ODP']))) ->get(['id','tenant_id','code','name','capacity']), 'sales_users' => User::query()->where('status','active')->whereHas('userTenants', fn($q)=>$q->when($tenantId,fn($x)=>$x->where('tenant_id',$tenantId))->where('role','staff')) ->with(['userTenants'=>fn($q)=>$q->when($tenantId,fn($x)=>$x->where('tenant_id',$tenantId))->select('id','user_id','tenant_id','role','department')])->orderBy('name')->get(['id','name','username']), 'installation_setting' => $tenantId ? \App\Models\TenantInstallationSetting::firstOrCreate(['tenant_id' => $tenantId], ['activation_mode' => 'list_order']) : null, ]; } public function availableOdpPorts(int $nodeId, User $actor, ?int $tenantId, ?int $customerId=null) { $node=TopologyNode::whereKey($nodeId)->whereHas('deviceType',fn($q)=>$q->whereRaw('UPPER(code) = ?', ['ODP']))->when(!$actor->isMasterAdmin(),fn($q)=>$q->where('tenant_id',$tenantId))->firstOrFail(); if((int)$node->capacity<1) throw ValidationException::withMessages(['topology_id'=>'Capacity ODP belum dikonfigurasi.']); $currentNumber=$customerId?Customer::whereKey($customerId)->where('tenant_id',$node->tenant_id)->where('topology_id',$node->id)->value('topology_port_number'):null; $used=TopologyPort::where('node_id',$node->id)->when($currentNumber,fn($q)=>$q->where('port_number','!=',$currentNumber))->pluck('port_number')->map(fn($number)=>(int)$number)->all(); return collect(range(1,(int)$node->capacity))->reject(fn($number)=>in_array($number,$used,true))->map(fn($number)=>['port_number'=>$number,'name'=>"Port {$number}",'node_id'=>$node->id])->values(); } public function ensureActivationCode(Customer $customer): Customer { if ($customer->customer_code) return $customer; DB::transaction(function () use ($customer) { DB::statement('SELECT pg_advisory_xact_lock(?)', [(int) $customer->tenant_id]); $locked = Customer::whereKey($customer->id)->lockForUpdate()->firstOrFail(); if (! $locked->customer_code) $locked->update(['customer_code' => $this->nextActivationCode((int) $locked->tenant_id)]); }); return $customer->fresh(); } private function createInstallationTicketWhenRequired(Customer $customer, User $actor): void { $mode = \App\Models\TenantInstallationSetting::firstOrCreate(['tenant_id'=>$customer->tenant_id],['activation_mode'=>'list_order'])->activation_mode; if ($mode !== 'ticket' || $customer->status !== 'order') return; DB::transaction(function () use ($customer, $actor) { if (Ticket::where('customer_id',$customer->id)->where('source','new_installation')->whereNotIn('status',['cancelled','rejected'])->exists()) return; $type=TicketType::firstOrCreate(['code'=>'NEW-INSTALLATION'],['name'=>'Pemasangan Baru','need_approval'=>true,'sla_minutes'=>1440,'require_photo'=>true,'require_material'=>false,'need_customer'=>true,'need_incident_type'=>false]); $ticket=Ticket::create(['ticket_no'=>$this->ticketService->generateNo(),'ticket_type_id'=>$type->id,'tenant_id'=>$customer->tenant_id,'customer_id'=>$customer->id,'source'=>'new_installation','installation_step'=>'documentation','title'=>'Pemasangan Baru - '.$customer->name,'description'=>'Dibuat otomatis dari registrasi List Order.','priority'=>'medium','status'=>'open','created_by'=>$actor->id]); TicketLog::create(['ticket_id'=>$ticket->id,'user_id'=>$actor->id,'activity'=>'Ticket Pemasangan Dibuat','notes'=>'Dibuat otomatis dari registrasi customer.','created_at'=>now()]); }); } private function nextActivationCode(int $tenantId): string { $time=now(); do{$code=$time->format('YmdHis');$exists=Customer::withTrashed()->where('tenant_id',$tenantId)->where('customer_code',$code)->exists();if($exists)$time->addSecond();}while($exists);return $code; } private function syncTopologyReservation(Customer $customer,int $nodeId,int $portNumber): void { $node=TopologyNode::whereKey($nodeId)->where('tenant_id',$customer->tenant_id)->whereHas('deviceType',fn($q)=>$q->whereRaw('UPPER(code) = ?', ['ODP']))->lockForUpdate()->firstOrFail(); if($portNumber<1||$portNumber>(int)$node->capacity) throw ValidationException::withMessages(['topology_port_number'=>"Nomor port harus antara 1 sampai {$node->capacity}."]); $current=$customer->topologyConnections()->whereIn('status',['reserved','active'])->with('odpPort')->first(); if($current && (int)$current->odp_node_id===$node->id && (int)$current->odpPort?->port_number===$portNumber) { $current->odpPort->update(['name'=>"Port {$portNumber} - {$customer->name}",'direction'=>'output']); return; } if(TopologyPort::where('node_id',$node->id)->where('port_number',$portNumber)->exists()) throw ValidationException::withMessages(['topology_port_number'=>'Port ODP sudah digunakan customer lain.']); if($current){$oldPort=$current->odpPort;$current->delete();$oldPort?->delete();} $port=TopologyPort::create(['tenant_id'=>$customer->tenant_id,'node_id'=>$node->id,'name'=>"Port {$portNumber} - {$customer->name}",'port_number'=>$portNumber,'port_type'=>'fiber','direction'=>'output','capacity'=>1,'status'=>$customer->status==='active'?'used':'reserved']); TopologyCustomerConnection::create(['customer_id'=>$customer->id,'odp_port_id'=>$port->id,'tenant_id'=>$customer->tenant_id,'odp_node_id'=>$node->id,'status'=>$customer->status==='active'?'active':'reserved']); } private function releaseTopologyPort(Customer $customer): void { DB::transaction(function () use ($customer) { $customer->topologyConnections()->whereIn('status',['reserved','active'])->with('odpPort')->get()->each(function($connection){$port=$connection->odpPort;$connection->delete();$port?->delete();}); }); } private function validateTenantRelations(array $data): void { $tenantId = (int) $data['tenant_id']; if (! empty($data['package_profile_id']) && ! NasPackageProfile::whereKey($data['package_profile_id'])->where('tenant_id', $tenantId)->exists()) { throw ValidationException::withMessages(['package_profile_id' => 'Profile paket bukan milik tenant customer.']); } if (! empty($data['nas_mikrotik_id']) && ! NasMikrotik::whereKey($data['nas_mikrotik_id'])->where('tenant_id', $tenantId)->exists()) { throw ValidationException::withMessages(['nas_mikrotik_id' => 'NAS Mikrotik bukan milik tenant customer.']); } if (! empty($data['billing_profile_id']) && ! BillingProfile::whereKey($data['billing_profile_id'])->where('tenant_id', $tenantId)->exists()) { throw ValidationException::withMessages(['billing_profile_id' => 'Profile tagihan bukan milik tenant customer.']); } if (! empty($data['sales_user_id']) && ! User::whereKey($data['sales_user_id'])->where('status','active')->whereHas('userTenants',fn($q)=>$q->where('tenant_id',$tenantId)->where('role','staff'))->exists()) { throw ValidationException::withMessages(['sales_user_id'=>'Sales/penanggung jawab bukan staff aktif tenant customer.']); } } private function validateWilayah(array $data): void { if (empty($data['desa_id'])) { return; } $desa = WilayahDesa::with('kecamatan.kabupaten')->find($data['desa_id']); if (! $desa || (! empty($data['kecamatan_id']) && (int) $desa->kecamatan_id !== (int) $data['kecamatan_id']) || (! empty($data['kabupaten_id']) && (int) $desa->kecamatan->kabupaten_id !== (int) $data['kabupaten_id']) || (! empty($data['provinsi_id']) && (int) $desa->kecamatan->kabupaten->provinsi_id !== (int) $data['provinsi_id'])) { throw ValidationException::withMessages(['desa_id' => 'Hierarki wilayah pemasangan tidak sesuai.']); } } private function syncImages(Customer $customer, array $images, User $actor): void { $coverAssigned = false; $rows = collect($images)->map(function (array $image, int $index) use ($customer, $actor, &$coverAssigned) { $file = StoredFile::findOrFail($image['stored_file_id']); $allowed = $actor->isMasterAdmin() || $file->uploaded_by === $actor->id || ((int) $file->tenant_id === (int) $customer->tenant_id); if (! $allowed || $file->category !== 'customer-image' || ! str_starts_with((string) $file->mime_type, 'image/')) { throw ValidationException::withMessages(['images' => 'File galeri customer tidak valid atau tidak dapat diakses.']); } $isCover = ! $coverAssigned && (bool) ($image['is_cover'] ?? $index === 0); $coverAssigned = $coverAssigned || $isCover; return [ 'stored_file_id' => $file->id, 'category' => $image['category'], 'caption' => $image['caption'] ?? null, 'is_cover' => $isCover, 'sort_order' => $image['sort_order'] ?? $index, ]; })->all(); $customer->images()->delete(); if ($rows) { $customer->images()->createMany($rows); } } private function syncPrimaryContacts(Customer $customer): void { foreach (['email' => $customer->email, 'whatsapp' => $customer->whatsapp_number] as $type => $value) { if (! $value) { continue; } CustomerContact::where('customer_id', $customer->id)->where('type', $type)->where('value', '!=', $value) ->update(['is_primary' => false]); CustomerContact::updateOrCreate( ['customer_id' => $customer->id, 'type' => $type, 'value' => $value], [ 'tenant_id' => $customer->tenant_id, 'label' => 'Kontak utama', 'is_primary' => true, 'can_receive_transactional' => true, 'can_receive_broadcast' => true, ], ); } } }