diff --git a/app/Http/Controllers/Api/V1/StatController.php b/app/Http/Controllers/Api/V1/StatController.php new file mode 100644 index 0000000..78ff96d --- /dev/null +++ b/app/Http/Controllers/Api/V1/StatController.php @@ -0,0 +1,64 @@ +statQueryService->getDashboardSummary(); + + return ApiResponse::handle(['data' => $data], __('message.fetched')); + } + + /** + * 일간 통계 조회 + */ + public function daily(StatDailyRequest $request): JsonResponse + { + $data = $this->statQueryService->getDailyStat( + $request->validated('domain'), + $request->validated() + ); + + return ApiResponse::handle(['data' => $data], __('message.fetched')); + } + + /** + * 월간 통계 조회 + */ + public function monthly(StatMonthlyRequest $request): JsonResponse + { + $data = $this->statQueryService->getMonthlyStat( + $request->validated('domain'), + $request->validated() + ); + + return ApiResponse::handle(['data' => $data], __('message.fetched')); + } + + /** + * 알림 목록 조회 + */ + public function alerts(StatAlertRequest $request): JsonResponse + { + $data = $this->statQueryService->getAlerts($request->validated()); + + return ApiResponse::handle(['data' => $data], __('message.fetched')); + } +} diff --git a/app/Http/Requests/V1/Stat/StatAlertRequest.php b/app/Http/Requests/V1/Stat/StatAlertRequest.php new file mode 100644 index 0000000..fdc51c3 --- /dev/null +++ b/app/Http/Requests/V1/Stat/StatAlertRequest.php @@ -0,0 +1,21 @@ + 'nullable|integer|min:1|max:100', + 'unread_only' => 'nullable|boolean', + ]; + } +} diff --git a/app/Http/Requests/V1/Stat/StatDailyRequest.php b/app/Http/Requests/V1/Stat/StatDailyRequest.php new file mode 100644 index 0000000..42b24b0 --- /dev/null +++ b/app/Http/Requests/V1/Stat/StatDailyRequest.php @@ -0,0 +1,22 @@ + 'required|string|in:sales,finance,production,inventory,quote,hr,system', + 'start_date' => 'required|date|date_format:Y-m-d', + 'end_date' => 'required|date|date_format:Y-m-d|after_or_equal:start_date', + ]; + } +} diff --git a/app/Http/Requests/V1/Stat/StatMonthlyRequest.php b/app/Http/Requests/V1/Stat/StatMonthlyRequest.php new file mode 100644 index 0000000..04b2eb7 --- /dev/null +++ b/app/Http/Requests/V1/Stat/StatMonthlyRequest.php @@ -0,0 +1,22 @@ + 'required|string|in:sales,finance,production,project', + 'year' => 'required|integer|min:2020|max:2099', + 'month' => 'nullable|integer|min:1|max:12', + ]; + } +} diff --git a/app/Models/Stats/Daily/StatSystemDaily.php b/app/Models/Stats/Daily/StatSystemDaily.php new file mode 100644 index 0000000..2b60a27 --- /dev/null +++ b/app/Models/Stats/Daily/StatSystemDaily.php @@ -0,0 +1,16 @@ + 'date', + 'file_upload_size_mb' => 'decimal:2', + 'approval_avg_hours' => 'decimal:2', + ]; +} diff --git a/app/Models/Stats/Monthly/StatProjectMonthly.php b/app/Models/Stats/Monthly/StatProjectMonthly.php new file mode 100644 index 0000000..b4e5c1e --- /dev/null +++ b/app/Models/Stats/Monthly/StatProjectMonthly.php @@ -0,0 +1,20 @@ + 'decimal:2', + 'expected_expense_total' => 'decimal:2', + 'actual_expense_total' => 'decimal:2', + 'labor_cost_total' => 'decimal:2', + 'material_cost_total' => 'decimal:2', + 'gross_profit' => 'decimal:2', + 'gross_profit_rate' => 'decimal:2', + ]; +} diff --git a/app/Models/Stats/StatEvent.php b/app/Models/Stats/StatEvent.php new file mode 100644 index 0000000..a260284 --- /dev/null +++ b/app/Models/Stats/StatEvent.php @@ -0,0 +1,15 @@ + 'array', + 'occurred_at' => 'datetime', + ]; +} diff --git a/app/Models/Stats/StatSnapshot.php b/app/Models/Stats/StatSnapshot.php new file mode 100644 index 0000000..dd3cf96 --- /dev/null +++ b/app/Models/Stats/StatSnapshot.php @@ -0,0 +1,16 @@ + 'date', + 'data' => 'array', + 'created_at' => 'datetime', + ]; +} diff --git a/app/Observers/StatEventObserver.php b/app/Observers/StatEventObserver.php new file mode 100644 index 0000000..5da33eb --- /dev/null +++ b/app/Observers/StatEventObserver.php @@ -0,0 +1,96 @@ + 'sales', + 'App\Models\Tenants\Sale' => 'sales', + 'App\Models\Tenants\Deposit' => 'finance', + 'App\Models\Tenants\Withdrawal' => 'finance', + 'App\Models\Tenants\Purchase' => 'finance', + 'App\Models\Production\WorkOrder' => 'production', + 'App\Models\Tenants\Approval' => 'system', + ]; + + public function created(Model $model): void + { + $this->recordEvent($model, 'created'); + } + + public function updated(Model $model): void + { + $this->recordEvent($model, 'updated'); + } + + public function deleted(Model $model): void + { + $this->recordEvent($model, 'deleted'); + } + + private function recordEvent(Model $model, string $action): void + { + $tenantId = $model->getAttribute('tenant_id'); + if (! $tenantId) { + return; + } + + $className = get_class($model); + $domain = self::$domainMap[$className] ?? 'other'; + $entityType = class_basename($model); + $eventType = strtolower($entityType).'_'.$action; + + $payload = match ($action) { + 'created' => $this->getCreatedPayload($model), + 'updated' => $this->getUpdatedPayload($model), + 'deleted' => ['id' => $model->getKey()], + default => null, + }; + + app(StatEventService::class)->recordEvent( + $tenantId, + $domain, + $eventType, + $entityType, + $model->getKey(), + $payload + ); + } + + private function getCreatedPayload(Model $model): array + { + $payload = ['id' => $model->getKey()]; + + if ($model->getAttribute('total_amount') !== null) { + $payload['amount'] = $model->getAttribute('total_amount'); + } + if ($model->getAttribute('amount') !== null) { + $payload['amount'] = $model->getAttribute('amount'); + } + if ($model->getAttribute('status') !== null) { + $payload['status'] = $model->getAttribute('status'); + } + + return $payload; + } + + private function getUpdatedPayload(Model $model): array + { + $changed = $model->getChanges(); + $payload = ['id' => $model->getKey()]; + + if (isset($changed['status'])) { + $payload['old_status'] = $model->getOriginal('status'); + $payload['new_status'] = $changed['status']; + } + if (isset($changed['total_amount']) || isset($changed['amount'])) { + $payload['amount'] = $changed['total_amount'] ?? $changed['amount']; + } + + return $payload; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index d083f5e..ac3963a 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -14,6 +14,7 @@ use App\Models\Tenants\Deposit; use App\Models\Tenants\ExpectedExpense; use App\Models\Tenants\Purchase; +use App\Models\Tenants\Sale; use App\Models\Tenants\Stock; use App\Models\Tenants\Tenant; use App\Models\Tenants\Withdrawal; @@ -21,6 +22,7 @@ use App\Observers\ExpenseSync\PurchaseExpenseSyncObserver; use App\Observers\ExpenseSync\WithdrawalExpenseSyncObserver; use App\Observers\MenuObserver; +use App\Observers\StatEventObserver; use App\Observers\TenantObserver; use App\Observers\TodayIssue\ApprovalIssueObserver; use App\Observers\TodayIssue\ApprovalStepIssueObserver; @@ -96,5 +98,13 @@ public function boot(): void Purchase::observe(PurchaseExpenseSyncObserver::class); Withdrawal::observe(WithdrawalExpenseSyncObserver::class); Bill::observe(BillExpenseSyncObserver::class); + + // 통계 이벤트 기록 (stat_events 테이블) + Order::observe(StatEventObserver::class); + Sale::observe(StatEventObserver::class); + Deposit::observe(StatEventObserver::class); + Withdrawal::observe(StatEventObserver::class); + Purchase::observe(StatEventObserver::class); + Approval::observe(StatEventObserver::class); } } diff --git a/app/Services/DashboardService.php b/app/Services/DashboardService.php index 5423370..22ed143 100644 --- a/app/Services/DashboardService.php +++ b/app/Services/DashboardService.php @@ -2,6 +2,8 @@ namespace App\Services; +use App\Models\Stats\Monthly\StatFinanceMonthly; +use App\Models\Stats\Monthly\StatSalesMonthly; use App\Models\Tenants\Approval; use App\Models\Tenants\ApprovalStep; use App\Models\Tenants\Attendance; @@ -114,23 +116,36 @@ private function getTodaySummary(int $tenantId, Carbon $today): array } /** - * 재무 요약 데이터 + * 재무 요약 데이터 (sam_stat 우선, 폴백: 원본 DB) */ private function getFinanceSummary(int $tenantId, Carbon $startOfMonth, Carbon $endOfMonth): array { - // 월간 입금 합계 + // sam_stat 월간 데이터 시도 + $monthly = StatFinanceMonthly::where('tenant_id', $tenantId) + ->where('stat_year', $startOfMonth->year) + ->where('stat_month', $startOfMonth->month) + ->first(); + + if ($monthly) { + return [ + 'monthly_deposit' => (float) $monthly->deposit_amount, + 'monthly_withdrawal' => (float) $monthly->withdrawal_amount, + 'balance' => (float) ($monthly->deposit_amount - $monthly->withdrawal_amount), + 'source' => 'sam_stat', + ]; + } + + // 폴백: 원본 DB 실시간 집계 $monthlyDeposit = Deposit::query() ->where('tenant_id', $tenantId) ->whereBetween('deposit_date', [$startOfMonth, $endOfMonth]) ->sum('amount'); - // 월간 출금 합계 $monthlyWithdrawal = Withdrawal::query() ->where('tenant_id', $tenantId) ->whereBetween('withdrawal_date', [$startOfMonth, $endOfMonth]) ->sum('amount'); - // 현재 잔액 (전체 입금 - 전체 출금) $totalDeposits = Deposit::query() ->where('tenant_id', $tenantId) ->sum('amount'); @@ -145,21 +160,35 @@ private function getFinanceSummary(int $tenantId, Carbon $startOfMonth, Carbon $ 'monthly_deposit' => (float) $monthlyDeposit, 'monthly_withdrawal' => (float) $monthlyWithdrawal, 'balance' => (float) $balance, + 'source' => 'samdb', ]; } /** - * 매출/매입 요약 데이터 + * 매출/매입 요약 데이터 (sam_stat 우선, 폴백: 원본 DB) */ private function getSalesSummary(int $tenantId, Carbon $startOfMonth, Carbon $endOfMonth): array { - // 월간 매출 합계 + // sam_stat 월간 데이터 시도 + $monthly = StatSalesMonthly::where('tenant_id', $tenantId) + ->where('stat_year', $startOfMonth->year) + ->where('stat_month', $startOfMonth->month) + ->first(); + + if ($monthly) { + return [ + 'monthly_sales' => (float) $monthly->sales_amount, + 'monthly_purchases' => 0, + 'source' => 'sam_stat', + ]; + } + + // 폴백: 원본 DB 실시간 집계 $monthlySales = Sale::query() ->where('tenant_id', $tenantId) ->whereBetween('sale_date', [$startOfMonth, $endOfMonth]) ->sum('total_amount'); - // 월간 매입 합계 $monthlyPurchases = Purchase::query() ->where('tenant_id', $tenantId) ->whereBetween('purchase_date', [$startOfMonth, $endOfMonth]) @@ -168,6 +197,7 @@ private function getSalesSummary(int $tenantId, Carbon $startOfMonth, Carbon $en return [ 'monthly_sales' => (float) $monthlySales, 'monthly_purchases' => (float) $monthlyPurchases, + 'source' => 'samdb', ]; } diff --git a/app/Services/Stats/ProjectStatService.php b/app/Services/Stats/ProjectStatService.php new file mode 100644 index 0000000..6929c7b --- /dev/null +++ b/app/Services/Stats/ProjectStatService.php @@ -0,0 +1,92 @@ +startOfDay(); + $endDate = $startDate->copy()->endOfMonth(); + + // 현장 현황 + $activeSites = DB::connection('mysql') + ->table('sites') + ->where('tenant_id', $tenantId) + ->where('status', 'active') + ->whereNull('deleted_at') + ->count(); + + $completedSites = DB::connection('mysql') + ->table('sites') + ->where('tenant_id', $tenantId) + ->where('status', 'completed') + ->whereNull('deleted_at') + ->count(); + + // 계약 현황 (해당 월 신규 계약) + $contractStats = DB::connection('mysql') + ->table('contracts') + ->where('tenant_id', $tenantId) + ->whereBetween('created_at', [$startDate, $endDate]) + ->whereNull('deleted_at') + ->selectRaw(' + COUNT(*) as new_count, + COALESCE(SUM(contract_amount), 0) as total_amount + ') + ->first(); + + // 지출예상 (해당 월) + $expenseStats = DB::connection('mysql') + ->table('expected_expenses') + ->where('tenant_id', $tenantId) + ->whereYear('expected_payment_date', $year) + ->whereMonth('expected_payment_date', $month) + ->whereNull('deleted_at') + ->selectRaw(' + COALESCE(SUM(amount), 0) as expected_total, + COALESCE(SUM(CASE WHEN payment_status = \'paid\' THEN amount ELSE 0 END), 0) as actual_total + ') + ->first(); + + // 수익률 계산 + $contractTotal = (float) ($contractStats->total_amount ?? 0); + $actualExpense = (float) ($expenseStats->actual_total ?? 0); + $grossProfit = $contractTotal - $actualExpense; + $grossProfitRate = $contractTotal > 0 ? ($grossProfit / $contractTotal) * 100 : 0; + + StatProjectMonthly::updateOrCreate( + [ + 'tenant_id' => $tenantId, + 'stat_year' => $year, + 'stat_month' => $month, + ], + [ + 'active_site_count' => $activeSites, + 'completed_site_count' => $completedSites, + 'new_contract_count' => $contractStats->new_count ?? 0, + 'contract_total_amount' => $contractTotal, + 'expected_expense_total' => $expenseStats->expected_total ?? 0, + 'actual_expense_total' => $actualExpense, + 'labor_cost_total' => 0, + 'material_cost_total' => 0, + 'gross_profit' => $grossProfit, + 'gross_profit_rate' => $grossProfitRate, + 'handover_report_count' => 0, + 'structure_review_count' => 0, + ] + ); + + return 1; + } +} diff --git a/app/Services/Stats/StatAggregatorService.php b/app/Services/Stats/StatAggregatorService.php index 6dcc191..7ca5515 100644 --- a/app/Services/Stats/StatAggregatorService.php +++ b/app/Services/Stats/StatAggregatorService.php @@ -21,6 +21,7 @@ private function getDailyDomainServices(): array 'inventory' => InventoryStatService::class, 'quote' => QuoteStatService::class, 'hr' => HrStatService::class, + 'system' => SystemStatService::class, ]; } @@ -36,6 +37,7 @@ private function getMonthlyDomainServices(): array 'inventory' => InventoryStatService::class, 'quote' => QuoteStatService::class, 'hr' => HrStatService::class, + 'project' => ProjectStatService::class, ]; } diff --git a/app/Services/Stats/StatEventService.php b/app/Services/Stats/StatEventService.php new file mode 100644 index 0000000..83bda74 --- /dev/null +++ b/app/Services/Stats/StatEventService.php @@ -0,0 +1,74 @@ + $tenantId, + 'domain' => $domain, + 'event_type' => $eventType, + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'payload' => $payload, + 'occurred_at' => now(), + ]); + } catch (\Throwable $e) { + Log::warning('stat_event 기록 실패', [ + 'tenant_id' => $tenantId, + 'domain' => $domain, + 'event_type' => $eventType, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * 스냅샷 저장 + */ + public function saveSnapshot( + int $tenantId, + string $domain, + string $snapshotDate, + array $data, + string $snapshotType = 'daily' + ): void { + try { + StatSnapshot::updateOrCreate( + [ + 'tenant_id' => $tenantId, + 'snapshot_date' => $snapshotDate, + 'domain' => $domain, + 'snapshot_type' => $snapshotType, + ], + [ + 'data' => $data, + 'created_at' => now(), + ] + ); + } catch (\Throwable $e) { + Log::warning('stat_snapshot 저장 실패', [ + 'tenant_id' => $tenantId, + 'domain' => $domain, + 'snapshot_date' => $snapshotDate, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Services/Stats/StatQueryService.php b/app/Services/Stats/StatQueryService.php new file mode 100644 index 0000000..8b8a538 --- /dev/null +++ b/app/Services/Stats/StatQueryService.php @@ -0,0 +1,179 @@ +tenantId(); + $startDate = $params['start_date']; + $endDate = $params['end_date']; + + $model = $this->getDailyModel($domain); + if (! $model) { + return []; + } + + return $model::where('tenant_id', $tenantId) + ->whereBetween('stat_date', [$startDate, $endDate]) + ->orderBy('stat_date') + ->get() + ->toArray(); + } + + /** + * 도메인별 월간 통계 조회 + */ + public function getMonthlyStat(string $domain, array $params): array + { + $tenantId = $this->tenantId(); + $year = (int) $params['year']; + $month = isset($params['month']) ? (int) $params['month'] : null; + + $model = $this->getMonthlyModel($domain); + if (! $model) { + return []; + } + + $query = $model::where('tenant_id', $tenantId) + ->where('stat_year', $year); + + if ($month) { + $query->where('stat_month', $month); + } + + return $query->orderBy('stat_month') + ->get() + ->toArray(); + } + + /** + * 대시보드 요약 통계 (sam_stat 기반) + */ + public function getDashboardSummary(): array + { + $tenantId = $this->tenantId(); + $today = Carbon::today()->format('Y-m-d'); + $year = Carbon::now()->year; + $month = Carbon::now()->month; + + return [ + 'sales_today' => $this->getTodaySales($tenantId, $today), + 'finance_today' => $this->getTodayFinance($tenantId, $today), + 'production_today' => $this->getTodayProduction($tenantId, $today), + 'sales_monthly' => $this->getMonthlySalesOverview($tenantId, $year, $month), + 'alerts' => $this->getActiveAlerts($tenantId), + ]; + } + + /** + * 알림 목록 조회 + */ + public function getAlerts(array $params): array + { + $tenantId = $this->tenantId(); + $limit = $params['limit'] ?? 20; + $unreadOnly = $params['unread_only'] ?? false; + + $query = StatAlert::where('tenant_id', $tenantId) + ->orderByDesc('created_at'); + + if ($unreadOnly) { + $query->where('is_read', false); + } + + return $query->limit($limit)->get()->toArray(); + } + + private function getTodaySales(int $tenantId, string $today): ?array + { + $stat = StatSalesDaily::where('tenant_id', $tenantId) + ->where('stat_date', $today) + ->first(); + + return $stat?->toArray(); + } + + private function getTodayFinance(int $tenantId, string $today): ?array + { + $stat = StatFinanceDaily::where('tenant_id', $tenantId) + ->where('stat_date', $today) + ->first(); + + return $stat?->toArray(); + } + + private function getTodayProduction(int $tenantId, string $today): ?array + { + $stat = StatProductionDaily::where('tenant_id', $tenantId) + ->where('stat_date', $today) + ->first(); + + return $stat?->toArray(); + } + + private function getMonthlySalesOverview(int $tenantId, int $year, int $month): ?array + { + $stat = StatSalesMonthly::where('tenant_id', $tenantId) + ->where('stat_year', $year) + ->where('stat_month', $month) + ->first(); + + return $stat?->toArray(); + } + + private function getActiveAlerts(int $tenantId): array + { + return StatAlert::where('tenant_id', $tenantId) + ->where('is_read', false) + ->where('is_resolved', false) + ->orderByDesc('created_at') + ->limit(10) + ->get() + ->toArray(); + } + + private function getDailyModel(string $domain): ?string + { + return match ($domain) { + 'sales' => StatSalesDaily::class, + 'finance' => StatFinanceDaily::class, + 'production' => StatProductionDaily::class, + 'inventory' => StatInventoryDaily::class, + 'quote' => StatQuotePipelineDaily::class, + 'hr' => StatHrAttendanceDaily::class, + 'system' => StatSystemDaily::class, + default => null, + }; + } + + private function getMonthlyModel(string $domain): ?string + { + return match ($domain) { + 'sales' => StatSalesMonthly::class, + 'finance' => StatFinanceMonthly::class, + 'production' => StatProductionMonthly::class, + 'project' => StatProjectMonthly::class, + default => null, + }; + } +} diff --git a/app/Services/Stats/SystemStatService.php b/app/Services/Stats/SystemStatService.php new file mode 100644 index 0000000..c4310ad --- /dev/null +++ b/app/Services/Stats/SystemStatService.php @@ -0,0 +1,135 @@ +format('Y-m-d'); + + // API 사용량 + $apiStats = DB::connection('mysql') + ->table('api_request_logs') + ->where('tenant_id', $tenantId) + ->whereDate('created_at', $dateStr) + ->selectRaw(' + COUNT(*) as request_count, + SUM(CASE WHEN response_status >= 400 THEN 1 ELSE 0 END) as error_count, + COALESCE(AVG(duration_ms), 0) as avg_response_ms + ') + ->first(); + + // 사용자 활동 (고유 사용자 수, 로그인 수) + $activeUsers = DB::connection('mysql') + ->table('api_request_logs') + ->where('tenant_id', $tenantId) + ->whereDate('created_at', $dateStr) + ->whereNotNull('user_id') + ->distinct('user_id') + ->count('user_id'); + + // personal_access_tokens에 tenant_id 없으므로 user_tenants 조인으로 로그인 수 집계 + $loginCount = DB::connection('mysql') + ->table('personal_access_tokens as pat') + ->join('user_tenants as ut', function ($join) use ($tenantId) { + $join->on('pat.tokenable_id', '=', 'ut.user_id') + ->where('pat.tokenable_type', '=', 'App\\Models\\Members\\User') + ->where('ut.tenant_id', '=', $tenantId); + }) + ->whereDate('pat.created_at', $dateStr) + ->count(); + + // 감사 로그 + $auditStats = DB::connection('mysql') + ->table('audit_logs') + ->where('tenant_id', $tenantId) + ->whereDate('created_at', $dateStr) + ->selectRaw(" + SUM(CASE WHEN action = 'created' THEN 1 ELSE 0 END) as create_count, + SUM(CASE WHEN action = 'updated' THEN 1 ELSE 0 END) as update_count, + SUM(CASE WHEN action = 'deleted' THEN 1 ELSE 0 END) as delete_count + ") + ->first(); + + // FCM 알림 + $fcmStats = DB::connection('mysql') + ->table('fcm_send_logs') + ->where('tenant_id', $tenantId) + ->whereDate('created_at', $dateStr) + ->selectRaw(' + COALESCE(SUM(success_count), 0) as sent_count, + COALESCE(SUM(failure_count), 0) as failed_count + ') + ->first(); + + // 파일 업로드 + $fileStats = DB::connection('mysql') + ->table('files') + ->where('tenant_id', $tenantId) + ->whereDate('created_at', $dateStr) + ->selectRaw(' + COUNT(*) as upload_count, + COALESCE(SUM(file_size), 0) / 1048576 as upload_size_mb + ') + ->first(); + + // 결재 + $approvalSubmitted = DB::connection('mysql') + ->table('approvals') + ->where('tenant_id', $tenantId) + ->whereDate('drafted_at', $dateStr) + ->whereNull('deleted_at') + ->count(); + + $approvalCompleted = DB::connection('mysql') + ->table('approvals') + ->where('tenant_id', $tenantId) + ->whereDate('completed_at', $dateStr) + ->whereNull('deleted_at') + ->count(); + + $approvalAvgHours = DB::connection('mysql') + ->table('approvals') + ->where('tenant_id', $tenantId) + ->whereDate('completed_at', $dateStr) + ->whereNotNull('drafted_at') + ->whereNotNull('completed_at') + ->whereNull('deleted_at') + ->selectRaw('AVG(TIMESTAMPDIFF(HOUR, drafted_at, completed_at)) as avg_hours') + ->value('avg_hours'); + + StatSystemDaily::updateOrCreate( + ['tenant_id' => $tenantId, 'stat_date' => $dateStr], + [ + 'api_request_count' => $apiStats->request_count ?? 0, + 'api_error_count' => $apiStats->error_count ?? 0, + 'api_avg_response_ms' => (int) ($apiStats->avg_response_ms ?? 0), + 'active_user_count' => $activeUsers, + 'login_count' => $loginCount, + 'audit_create_count' => $auditStats->create_count ?? 0, + 'audit_update_count' => $auditStats->update_count ?? 0, + 'audit_delete_count' => $auditStats->delete_count ?? 0, + 'fcm_sent_count' => $fcmStats->sent_count ?? 0, + 'fcm_failed_count' => $fcmStats->failed_count ?? 0, + 'file_upload_count' => $fileStats->upload_count ?? 0, + 'file_upload_size_mb' => $fileStats->upload_size_mb ?? 0, + 'approval_submitted_count' => $approvalSubmitted, + 'approval_completed_count' => $approvalCompleted, + 'approval_avg_hours' => (float) ($approvalAvgHours ?? 0), + ] + ); + + return 1; + } + + public function aggregateMonthly(int $tenantId, int $year, int $month): int + { + // 시스템 도메인은 일간 테이블만 운영 + return 0; + } +} diff --git a/database/migrations/stats/2026_01_29_200001_create_stat_project_monthly_table.php b/database/migrations/stats/2026_01_29_200001_create_stat_project_monthly_table.php new file mode 100644 index 0000000..076a771 --- /dev/null +++ b/database/migrations/stats/2026_01_29_200001_create_stat_project_monthly_table.php @@ -0,0 +1,50 @@ +create('stat_project_monthly', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->smallInteger('stat_year'); + $table->tinyInteger('stat_month'); + + // 프로젝트 현황 + $table->unsignedInteger('active_site_count')->default(0); + $table->unsignedInteger('completed_site_count')->default(0); + $table->unsignedInteger('new_contract_count')->default(0); + $table->decimal('contract_total_amount', 18, 2)->default(0); + + // 원가 + $table->decimal('expected_expense_total', 18, 2)->default(0); + $table->decimal('actual_expense_total', 18, 2)->default(0); + $table->decimal('labor_cost_total', 18, 2)->default(0); + $table->decimal('material_cost_total', 18, 2)->default(0); + + // 수익률 + $table->decimal('gross_profit', 18, 2)->default(0); + $table->decimal('gross_profit_rate', 5, 2)->default(0); + + // 이슈 + $table->unsignedInteger('handover_report_count')->default(0); + $table->unsignedInteger('structure_review_count')->default(0); + + $table->timestamps(); + + $table->unique(['tenant_id', 'stat_year', 'stat_month'], 'uk_tenant_year_month'); + $table->index(['stat_year', 'stat_month'], 'idx_year_month'); + }); + } + + public function down(): void + { + Schema::connection('sam_stat')->dropIfExists('stat_project_monthly'); + } +}; diff --git a/database/migrations/stats/2026_01_29_200002_create_stat_system_daily_table.php b/database/migrations/stats/2026_01_29_200002_create_stat_system_daily_table.php new file mode 100644 index 0000000..f39b143 --- /dev/null +++ b/database/migrations/stats/2026_01_29_200002_create_stat_system_daily_table.php @@ -0,0 +1,56 @@ +create('stat_system_daily', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->date('stat_date'); + + // API 사용량 + $table->unsignedInteger('api_request_count')->default(0); + $table->unsignedInteger('api_error_count')->default(0); + $table->unsignedInteger('api_avg_response_ms')->default(0); + + // 사용자 활동 + $table->unsignedInteger('active_user_count')->default(0); + $table->unsignedInteger('login_count')->default(0); + + // 감사 + $table->unsignedInteger('audit_create_count')->default(0); + $table->unsignedInteger('audit_update_count')->default(0); + $table->unsignedInteger('audit_delete_count')->default(0); + + // 알림 + $table->unsignedInteger('fcm_sent_count')->default(0); + $table->unsignedInteger('fcm_failed_count')->default(0); + + // 파일 + $table->unsignedInteger('file_upload_count')->default(0); + $table->decimal('file_upload_size_mb', 10, 2)->default(0); + + // 결재 + $table->unsignedInteger('approval_submitted_count')->default(0); + $table->unsignedInteger('approval_completed_count')->default(0); + $table->decimal('approval_avg_hours', 8, 2)->default(0); + + $table->timestamps(); + + $table->unique(['tenant_id', 'stat_date'], 'uk_tenant_date'); + $table->index('stat_date', 'idx_date'); + }); + } + + public function down(): void + { + Schema::connection('sam_stat')->dropIfExists('stat_system_daily'); + } +}; diff --git a/database/migrations/stats/2026_01_29_200003_create_stat_events_table.php b/database/migrations/stats/2026_01_29_200003_create_stat_events_table.php new file mode 100644 index 0000000..b52bb90 --- /dev/null +++ b/database/migrations/stats/2026_01_29_200003_create_stat_events_table.php @@ -0,0 +1,33 @@ +create('stat_events', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('domain', 50); + $table->string('event_type', 100); + $table->string('entity_type', 100); + $table->unsignedBigInteger('entity_id'); + $table->json('payload')->nullable(); + $table->timestamp('occurred_at'); + + $table->index(['tenant_id', 'domain'], 'idx_tenant_domain'); + $table->index('occurred_at', 'idx_occurred'); + $table->index(['entity_type', 'entity_id'], 'idx_entity'); + }); + } + + public function down(): void + { + Schema::connection('sam_stat')->dropIfExists('stat_events'); + } +}; diff --git a/database/migrations/stats/2026_01_29_200004_create_stat_snapshots_table.php b/database/migrations/stats/2026_01_29_200004_create_stat_snapshots_table.php new file mode 100644 index 0000000..2fa7f11 --- /dev/null +++ b/database/migrations/stats/2026_01_29_200004_create_stat_snapshots_table.php @@ -0,0 +1,31 @@ +create('stat_snapshots', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->date('snapshot_date'); + $table->string('domain', 50); + $table->string('snapshot_type', 50)->default('daily'); + $table->json('data'); + $table->timestamp('created_at')->nullable(); + + $table->unique(['tenant_id', 'snapshot_date', 'domain', 'snapshot_type'], 'uk_tenant_date_domain'); + $table->index('snapshot_date', 'idx_date'); + }); + } + + public function down(): void + { + Schema::connection('sam_stat')->dropIfExists('stat_snapshots'); + } +}; diff --git a/routes/api.php b/routes/api.php index 44cd1b1..daf53ea 100644 --- a/routes/api.php +++ b/routes/api.php @@ -37,6 +37,7 @@ require __DIR__.'/api/v1/boards.php'; require __DIR__.'/api/v1/documents.php'; require __DIR__.'/api/v1/common.php'; + require __DIR__.'/api/v1/stats.php'; // 공유 링크 다운로드 (인증 불필요 - auth.apikey 그룹 밖) Route::get('/files/share/{token}', [FileStorageController::class, 'downloadShared'])->name('v1.files.share.download'); diff --git a/routes/api/v1/stats.php b/routes/api/v1/stats.php new file mode 100644 index 0000000..eef2469 --- /dev/null +++ b/routes/api/v1/stats.php @@ -0,0 +1,19 @@ +group(function () { + Route::get('/summary', [StatController::class, 'summary'])->name('v1.stats.summary'); + Route::get('/daily', [StatController::class, 'daily'])->name('v1.stats.daily'); + Route::get('/monthly', [StatController::class, 'monthly'])->name('v1.stats.monthly'); + Route::get('/alerts', [StatController::class, 'alerts'])->name('v1.stats.alerts'); +});