From 2e284f63931f76d1a127a71ad85f720489224992 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Fri, 13 Mar 2026 22:52:47 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[demo]=20Phase=204=20=EA=B3=A0=EB=8F=84?= =?UTF-8?q?=ED=99=94=20=E2=80=94=20=EB=B6=84=EC=84=9D=20API,=20=EB=B9=84?= =?UTF-8?q?=ED=99=9C=EC=84=B1=20=EC=95=8C=EB=A6=BC,=20=ED=86=B5=EA=B3=84?= =?UTF-8?q?=20=EC=8B=9C=EB=94=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DemoAnalyticsService: 전환율 퍼널, 파트너 성과, 활동 현황, 대시보드 요약 - DemoAnalyticsController: 분석 API 4개 엔드포인트 - CheckDemoInactiveCommand: 7일 비활성 데모 테넌트 탐지 및 로그 알림 - ManufacturingPresetSeeder: sam_stat DB에 90일 매출/생산 통계 시딩 - 라우트: demo-analytics prefix 4개 GET 엔드포인트 등록 - 스케줄러: demo:check-inactive 매일 09:30 실행 --- .../Commands/CheckDemoInactiveCommand.php | 103 +++++++ .../Api/V1/DemoAnalyticsController.php | 62 ++++ app/Services/Demo/DemoAnalyticsService.php | 271 ++++++++++++++++++ .../Demo/ManufacturingPresetSeeder.php | 140 +++++++++ routes/api/v1/sales.php | 9 + routes/console.php | 11 + 6 files changed, 596 insertions(+) create mode 100644 app/Console/Commands/CheckDemoInactiveCommand.php create mode 100644 app/Http/Controllers/Api/V1/DemoAnalyticsController.php create mode 100644 app/Services/Demo/DemoAnalyticsService.php diff --git a/app/Console/Commands/CheckDemoInactiveCommand.php b/app/Console/Commands/CheckDemoInactiveCommand.php new file mode 100644 index 0000000..c718531 --- /dev/null +++ b/app/Console/Commands/CheckDemoInactiveCommand.php @@ -0,0 +1,103 @@ +option('days'); + + $demos = Tenant::withoutGlobalScopes() + ->whereIn('tenant_type', Tenant::DEMO_TYPES) + ->where('tenant_st_code', '!=', 'expired') + ->get(); + + if ($demos->isEmpty()) { + $this->info('활성 데모 테넌트 없음'); + + return self::SUCCESS; + } + + $inactiveCount = 0; + + foreach ($demos as $tenant) { + $lastActivity = $this->getLastActivity($tenant->id); + + if (! $lastActivity) { + continue; + } + + $daysSince = (int) now()->diffInDays($lastActivity); + + if ($daysSince < $thresholdDays) { + continue; + } + + $inactiveCount++; + $this->line(" - [{$tenant->id}] {$tenant->company_name} ({$daysSince}일 비활성)"); + + Log::warning('데모 테넌트 비활성 알림', [ + 'tenant_id' => $tenant->id, + 'company_name' => $tenant->company_name, + 'tenant_type' => $tenant->tenant_type, + 'days_inactive' => $daysSince, + 'last_activity' => $lastActivity->toDateString(), + 'partner_id' => $tenant->demo_source_partner_id, + ]); + } + + if ($inactiveCount === 0) { + $this->info("비활성 테넌트 없음 (기준: {$thresholdDays}일)"); + } else { + $this->info("비활성 테넌트: {$inactiveCount}건 (기준: {$thresholdDays}일)"); + } + + return self::SUCCESS; + } + + 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; + } +} diff --git a/app/Http/Controllers/Api/V1/DemoAnalyticsController.php b/app/Http/Controllers/Api/V1/DemoAnalyticsController.php new file mode 100644 index 0000000..65e825d --- /dev/null +++ b/app/Http/Controllers/Api/V1/DemoAnalyticsController.php @@ -0,0 +1,62 @@ +service->summary(); + }, __('message.fetched')); + } + + /** + * 전환율 퍼널 분석 + */ + public function conversionFunnel(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->conversionFunnel($request->all()); + }, __('message.fetched')); + } + + /** + * 파트너별 성과 분석 + */ + public function partnerPerformance(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->partnerPerformance($request->all()); + }, __('message.fetched')); + } + + /** + * 데모 테넌트 활동 현황 + */ + public function activityReport(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->activityReport($request->all()); + }, __('message.fetched')); + } +} diff --git a/app/Services/Demo/DemoAnalyticsService.php b/app/Services/Demo/DemoAnalyticsService.php new file mode 100644 index 0000000..24ce227 --- /dev/null +++ b/app/Services/Demo/DemoAnalyticsService.php @@ -0,0 +1,271 @@ +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', + }; + } +} diff --git a/database/seeders/Demo/ManufacturingPresetSeeder.php b/database/seeders/Demo/ManufacturingPresetSeeder.php index 8c50314..881fd66 100644 --- a/database/seeders/Demo/ManufacturingPresetSeeder.php +++ b/database/seeders/Demo/ManufacturingPresetSeeder.php @@ -34,6 +34,9 @@ public function run(int $tenantId): void // 5. 수주 $this->seedOrders($tenantId, $clientIds, $now); + + // 6. 통계 데이터 (대시보드 차트용, 최근 90일) + $this->seedStatData($tenantId); } private function seedDepartments(int $tenantId, $now): void @@ -245,4 +248,141 @@ private function seedOrders(int $tenantId, array $clientIds, $now): void ]); } } + + private function seedStatData(int $tenantId): void + { + // sam_stat DB에 통계 데이터가 있어야 대시보드 차트가 의미있게 보인다. + // 최근 90일치 일간 매출/생산 통계를 시드한다. + $statConnection = 'sam_stat'; + + // sam_stat 연결이 설정되어 있지 않으면 스킵 + if (! config("database.connections.{$statConnection}")) { + return; + } + + // 테이블 존재 여부 확인 + try { + $hasSalesTable = \Schema::connection($statConnection)->hasTable('stat_sales_daily'); + $hasProductionTable = \Schema::connection($statConnection)->hasTable('stat_production_daily'); + } catch (\Exception $e) { + return; // sam_stat DB 접속 불가 시 스킵 + } + + $today = now()->startOfDay(); + + // 매출 통계 (90일) + if ($hasSalesTable) { + $salesData = []; + for ($i = 89; $i >= 0; $i--) { + $date = $today->copy()->subDays($i); + + // 주말은 거래 없음 + if ($date->isWeekend()) { + continue; + } + + // 계절성 반영 — 월초에 주문 많고, 중반에 매출 확정 + $dayOfMonth = $date->day; + $orderBase = ($dayOfMonth <= 10) ? rand(2, 5) : rand(0, 3); + $salesBase = ($dayOfMonth >= 10 && $dayOfMonth <= 25) ? rand(1, 4) : rand(0, 2); + + // 성장 트렌드 (최근일수록 약간 증가) + $growthFactor = 1.0 + (90 - $i) * 0.003; + + $orderCount = (int) round($orderBase * $growthFactor); + $orderAmount = $orderCount * rand(15000000, 65000000); + $salesCount = (int) round($salesBase * $growthFactor); + $salesAmount = $salesCount * rand(20000000, 70000000); + + $salesData[] = [ + 'tenant_id' => $tenantId, + 'stat_date' => $date->toDateString(), + 'order_count' => $orderCount, + 'order_amount' => $orderAmount, + 'order_item_count' => $orderCount * rand(2, 8), + 'sales_count' => $salesCount, + 'sales_amount' => $salesAmount, + 'sales_tax_amount' => (int) round($salesAmount * 0.1), + 'new_client_count' => rand(0, 1), + 'active_client_count' => rand(5, 10), + 'order_draft_count' => rand(0, 2), + 'order_confirmed_count' => rand(1, 3), + 'order_in_progress_count' => rand(1, 4), + 'order_completed_count' => rand(0, 2), + 'order_cancelled_count' => rand(0, 1) > 0 ? 0 : rand(0, 1), + 'shipment_count' => $salesCount, + 'shipment_amount' => $salesAmount, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + // 기존 데이터 삭제 후 삽입 + DB::connection($statConnection)->table('stat_sales_daily') + ->where('tenant_id', $tenantId) + ->delete(); + + // 청크 단위 삽입 + foreach (array_chunk($salesData, 30) as $chunk) { + DB::connection($statConnection)->table('stat_sales_daily')->insert($chunk); + } + } + + // 생산 통계 (90일) + if ($hasProductionTable) { + $prodData = []; + for ($i = 89; $i >= 0; $i--) { + $date = $today->copy()->subDays($i); + + if ($date->isWeekend()) { + continue; + } + + $growthFactor = 1.0 + (90 - $i) * 0.002; + + $productionQty = (int) round(rand(50, 150) * $growthFactor); + $defectQty = (int) round($productionQty * rand(10, 40) / 1000); // 1~4% 불량률 + $defectRate = $productionQty > 0 ? round($defectQty / $productionQty * 100, 2) : 0; + + $plannedHours = rand(40, 80); + $actualHours = $plannedHours + rand(-10, 5); + $efficiencyRate = $actualHours > 0 ? round($plannedHours / $actualHours * 100, 2) : 0; + + $onTime = rand(3, 8); + $late = rand(0, 2); + $deliveryRate = ($onTime + $late) > 0 + ? round($onTime / ($onTime + $late) * 100, 2) : 100; + + $prodData[] = [ + 'tenant_id' => $tenantId, + 'stat_date' => $date->toDateString(), + 'wo_created_count' => rand(2, 6), + 'wo_completed_count' => rand(1, 5), + 'wo_in_progress_count' => rand(3, 10), + 'wo_overdue_count' => rand(0, 2), + 'production_qty' => $productionQty, + 'defect_qty' => $defectQty, + 'defect_rate' => $defectRate, + 'planned_hours' => $plannedHours, + 'actual_hours' => $actualHours, + 'efficiency_rate' => min($efficiencyRate, 120), + 'active_worker_count' => rand(8, 20), + 'issue_count' => rand(0, 3), + 'on_time_delivery_count' => $onTime, + 'late_delivery_count' => $late, + 'delivery_rate' => $deliveryRate, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + DB::connection($statConnection)->table('stat_production_daily') + ->where('tenant_id', $tenantId) + ->delete(); + + foreach (array_chunk($prodData, 30) as $chunk) { + DB::connection($statConnection)->table('stat_production_daily')->insert($chunk); + } + } + } } diff --git a/routes/api/v1/sales.php b/routes/api/v1/sales.php index 18a7f9e..9a5135e 100644 --- a/routes/api/v1/sales.php +++ b/routes/api/v1/sales.php @@ -14,6 +14,7 @@ use App\Http\Controllers\Api\V1\ClientController; use App\Http\Controllers\Api\V1\ClientGroupController; use App\Http\Controllers\Api\V1\CompanyController; +use App\Http\Controllers\Api\V1\DemoAnalyticsController; use App\Http\Controllers\Api\V1\DemoTenantController; use App\Http\Controllers\Api\V1\EstimateController; use App\Http\Controllers\Api\V1\OrderController; @@ -205,6 +206,14 @@ Route::post('/{id}/convert', [DemoTenantController::class, 'convert'])->whereNumber('id')->name('v1.demo-tenants.convert'); }); +// Demo Analytics API (데모 분석) +Route::prefix('demo-analytics')->group(function () { + Route::get('/summary', [DemoAnalyticsController::class, 'summary'])->name('v1.demo-analytics.summary'); + Route::get('/conversion-funnel', [DemoAnalyticsController::class, 'conversionFunnel'])->name('v1.demo-analytics.conversion-funnel'); + Route::get('/partner-performance', [DemoAnalyticsController::class, 'partnerPerformance'])->name('v1.demo-analytics.partner-performance'); + Route::get('/activity-report', [DemoAnalyticsController::class, 'activityReport'])->name('v1.demo-analytics.activity-report'); +}); + // Company API (회사 추가 관리) Route::prefix('companies')->group(function () { Route::post('/check', [CompanyController::class, 'check'])->name('v1.companies.check'); // 사업자등록번호 검증 diff --git a/routes/console.php b/routes/console.php index b18adcf..bff1ee1 100644 --- a/routes/console.php +++ b/routes/console.php @@ -170,6 +170,17 @@ \Illuminate\Support\Facades\Log::error('❌ demo:check-expired 스케줄러 실행 실패', ['time' => now()]); }); +// 매일 오전 09:30에 데모 테넌트 비활성 알림 (7일 이상 활동 없음) +Schedule::command('demo:check-inactive') + ->dailyAt('09:30') + ->appendOutputTo(storage_path('logs/scheduler.log')) + ->onSuccess(function () { + \Illuminate\Support\Facades\Log::info('✅ demo:check-inactive 스케줄러 실행 성공', ['time' => now()]); + }) + ->onFailure(function () { + \Illuminate\Support\Facades\Log::error('❌ demo:check-inactive 스케줄러 실행 실패', ['time' => now()]); + }); + // 매일 자정 00:00에 쇼케이스 테넌트 데이터 리셋 + 샘플 재시드 Schedule::command('demo:reset-showcase --seed') ->dailyAt('00:00')