- DemoAnalyticsService: 전환율 퍼널, 파트너 성과, 활동 현황, 대시보드 요약 - DemoAnalyticsController: 분석 API 4개 엔드포인트 - CheckDemoInactiveCommand: 7일 비활성 데모 테넌트 탐지 및 로그 알림 - ManufacturingPresetSeeder: sam_stat DB에 90일 매출/생산 통계 시딩 - 라우트: demo-analytics prefix 4개 GET 엔드포인트 등록 - 스케줄러: demo:check-inactive 매일 09:30 실행
272 lines
9.4 KiB
PHP
272 lines
9.4 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Demo;
|
|
|
|
use App\Models\Tenants\Tenant;
|
|
use App\Services\Service;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* 데모 테넌트 분석 서비스
|
|
*
|
|
* - 전환율 분석 (데모 → 정식)
|
|
* - 활동 모니터링 (데모 테넌트별 사용량)
|
|
* - 파트너별 영업 성과 분석
|
|
*
|
|
* 기존 코드 영향 없음: 데모 전용 분석 로직만 포함
|
|
*
|
|
* @see docs/features/sales/demo-tenant-policy.md
|
|
*/
|
|
class DemoAnalyticsService extends Service
|
|
{
|
|
/**
|
|
* 전환율 분석 (전체 또는 파트너별)
|
|
*/
|
|
public function conversionFunnel(array $params = []): array
|
|
{
|
|
$partnerId = $params['partner_id'] ?? null;
|
|
$period = $params['period'] ?? 'all'; // all, monthly, quarterly
|
|
|
|
$baseQuery = Tenant::withoutGlobalScopes();
|
|
|
|
if ($partnerId) {
|
|
$baseQuery->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',
|
|
};
|
|
}
|
|
}
|