where('demo_source_partner_id', $partnerId); } // Tier 3 체험 테넌트 전체 (현재 + 전환 완료) $totalTrials = (clone $baseQuery) ->where(function ($q) { $q->where('tenant_type', Tenant::TYPE_DEMO_TRIAL) ->orWhere(function ($q2) { // 정식 전환된 테넌트 (demo_source_partner_id가 있으면서 STD) $q2->where('tenant_type', Tenant::TYPE_STD) ->whereNotNull('demo_source_partner_id'); }); }) ->count(); // 활성 체험 중 $activeTrials = (clone $baseQuery) ->where('tenant_type', Tenant::TYPE_DEMO_TRIAL) ->where(function ($q) { $q->whereNull('demo_expires_at') ->orWhere('demo_expires_at', '>', now()); }) ->count(); // 만료된 체험 $expiredTrials = (clone $baseQuery) ->where('tenant_type', Tenant::TYPE_DEMO_TRIAL) ->whereNotNull('demo_expires_at') ->where('demo_expires_at', '<', now()) ->count(); // 정식 전환 완료 $converted = (clone $baseQuery) ->where('tenant_type', Tenant::TYPE_STD) ->whereNotNull('demo_source_partner_id') ->count(); $conversionRate = $totalTrials > 0 ? round($converted / $totalTrials * 100, 1) : 0; // 평균 전환 기간 (정식 전환된 건의 생성일 → 전환일 차이) $avgConversionDays = Tenant::withoutGlobalScopes() ->where('tenant_type', Tenant::TYPE_STD) ->whereNotNull('demo_source_partner_id') ->when($partnerId, fn ($q) => $q->where('demo_source_partner_id', $partnerId)) ->selectRaw('AVG(DATEDIFF(updated_at, created_at)) as avg_days') ->value('avg_days'); return [ 'funnel' => [ 'total_trials' => $totalTrials, 'active_trials' => $activeTrials, 'expired_trials' => $expiredTrials, 'converted' => $converted, ], 'conversion_rate' => $conversionRate, 'avg_conversion_days' => $avgConversionDays ? (int) round($avgConversionDays) : null, ]; } /** * 파트너별 성과 분석 */ public function partnerPerformance(array $params = []): array { $partners = DB::table('sales_partners as sp') ->leftJoin('users as u', 'sp.user_id', '=', 'u.id') ->where('sp.status', 'active') ->select('sp.id', 'sp.partner_code', 'u.name as partner_name') ->get(); $results = []; foreach ($partners as $partner) { $demos = Tenant::withoutGlobalScopes() ->where('demo_source_partner_id', $partner->id) ->get(); $trials = $demos->where('tenant_type', Tenant::TYPE_DEMO_TRIAL); $convertedCount = Tenant::withoutGlobalScopes() ->where('tenant_type', Tenant::TYPE_STD) ->where('demo_source_partner_id', $partner->id) ->count(); $totalTrials = $trials->count() + $convertedCount; $conversionRate = $totalTrials > 0 ? round($convertedCount / $totalTrials * 100, 1) : 0; $results[] = [ 'partner_id' => $partner->id, 'partner_code' => $partner->partner_code, 'partner_name' => $partner->partner_name, 'demo_count' => $demos->count(), 'active_trials' => $trials->filter(fn ($t) => ! $t->isDemoExpired())->count(), 'expired_trials' => $trials->filter(fn ($t) => $t->isDemoExpired())->count(), 'converted' => $convertedCount, 'conversion_rate' => $conversionRate, ]; } // 전환율 내림차순 정렬 usort($results, fn ($a, $b) => $b['conversion_rate'] <=> $a['conversion_rate']); return $results; } /** * 데모 테넌트 활동 현황 */ public function activityReport(array $params = []): array { $demos = Tenant::withoutGlobalScopes() ->whereIn('tenant_type', Tenant::DEMO_TYPES) ->when( ! empty($params['partner_id']), fn ($q) => $q->where('demo_source_partner_id', $params['partner_id']) ) ->get(); $report = []; foreach ($demos as $tenant) { // 각 데모 테넌트의 데이터 입력량 조회 $dataCounts = $this->getDataCounts($tenant->id); $totalRecords = array_sum($dataCounts); // 최근 활동 시점 (가장 최근 레코드의 updated_at) $lastActivity = $this->getLastActivity($tenant->id); $daysSinceActivity = $lastActivity ? (int) now()->diffInDays($lastActivity) : null; $report[] = [ 'tenant_id' => $tenant->id, 'company_name' => $tenant->company_name, 'tenant_type' => $tenant->tenant_type, 'demo_expires_at' => $tenant->demo_expires_at?->toDateString(), 'is_expired' => $tenant->isDemoExpired(), 'data_counts' => $dataCounts, 'total_records' => $totalRecords, 'last_activity' => $lastActivity?->toDateString(), 'days_since_activity' => $daysSinceActivity, 'activity_status' => $this->classifyActivity($daysSinceActivity), ]; } return $report; } /** * 전체 요약 (대시보드용) */ public function summary(): array { $funnel = $this->conversionFunnel(); $allDemos = Tenant::withoutGlobalScopes() ->whereIn('tenant_type', Tenant::DEMO_TYPES) ->get(); $inactiveCount = 0; foreach ($allDemos as $tenant) { $lastActivity = $this->getLastActivity($tenant->id); if ($lastActivity && now()->diffInDays($lastActivity) >= 7) { $inactiveCount++; } } return [ 'funnel' => $funnel['funnel'], 'conversion_rate' => $funnel['conversion_rate'], 'avg_conversion_days' => $funnel['avg_conversion_days'], 'total_demos' => $allDemos->count(), 'inactive_count' => $inactiveCount, 'by_type' => [ 'showcase' => $allDemos->where('tenant_type', Tenant::TYPE_DEMO_SHOWCASE)->count(), 'partner' => $allDemos->where('tenant_type', Tenant::TYPE_DEMO_PARTNER)->count(), 'trial' => $allDemos->where('tenant_type', Tenant::TYPE_DEMO_TRIAL)->count(), ], ]; } // ────────────────────────────────────────────── // Private Helpers // ────────────────────────────────────────────── private function getDataCounts(int $tenantId): array { $tables = ['departments', 'clients', 'items', 'quotes', 'orders']; $counts = []; foreach ($tables as $table) { if (\Schema::hasTable($table) && \Schema::hasColumn($table, 'tenant_id')) { $counts[$table] = DB::table($table)->where('tenant_id', $tenantId)->count(); } } return $counts; } private function getLastActivity(int $tenantId): ?\Carbon\Carbon { $tables = ['orders', 'quotes', 'items', 'clients']; $latest = null; foreach ($tables as $table) { if (! \Schema::hasTable($table) || ! \Schema::hasColumn($table, 'tenant_id')) { continue; } $date = DB::table($table) ->where('tenant_id', $tenantId) ->max('updated_at'); if ($date) { $parsed = \Carbon\Carbon::parse($date); if (! $latest || $parsed->gt($latest)) { $latest = $parsed; } } } return $latest; } private function classifyActivity(?int $daysSinceActivity): string { if ($daysSinceActivity === null) { return 'no_data'; } return match (true) { $daysSinceActivity <= 1 => 'active', $daysSinceActivity <= 3 => 'normal', $daysSinceActivity <= 7 => 'low', default => 'inactive', }; } }