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.']); } $data['customer_code'] = filled($data['customer_code'] ?? null) ? $data['customer_code'] : $this->nextCode((int) $data['tenant_id']); $data['source'] ??= 'manual'; $data['status'] = $data['source'] === 'manual' ? 'order' : 'unmanaged'; $data['order_stage'] ??= 'registration'; $this->validateTenantRelations($data); $this->validateWilayah($data); $customer = Customer::create($data); $this->syncImages($customer, $images, $actor); $this->syncPrimaryContacts($customer); $customer->load('tenant:id,tenant_name'); $this->notifications->emit( 'customer.registration', $customer, $customer, ['customer' => $customer->toArray(), 'tenant' => $customer->tenant?->toArray() ?? []], 'registration', ['action_url' => "/customers/orders/{$customer->id}/detail"], ); 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']); $customer->update($data); $this->syncPrimaryContacts($customer); if ($hasImages) { $this->syncImages($customer, $images, $actor); } return $customer->fresh()->load(self::DETAIL_RELATIONS); } public function activate(Customer $customer, array $data): 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.', ]); } $customer->update([ ...$data, 'status' => 'active', 'order_stage' => 'ready_activation', 'activated_at' => now(), 'inactivated_at' => null, ]); $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 { $customer->update(['status' => 'inactive', 'inactivated_at' => now()]); return $customer->fresh()->load(self::DETAIL_RELATIONS); } public function trash(Customer $customer): void { $customer->delete(); } public function restore(Customer $customer): Customer { $customer->restore(); return $customer->fresh()->load(self::DETAIL_RELATIONS); } public function forceDelete(Customer $customer): void { $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']), ]; } private function nextCode(int $tenantId): string { $next = ((int) Customer::withTrashed()->where('tenant_id', $tenantId)->max('id')) + 1; return 'CUST-'.str_pad((string) $next, 6, '0', STR_PAD_LEFT); } 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.']); } } 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, ], ); } } }