'تعداد یادگیرندگان یکتایی که تا پایان بازه، نسخه انتخابی به آن‌ها تخصیص یافته است.', 'starts' => 'تعداد یادگیرندگان تخصیص‌یافته‌ای که تا پایان بازه حداقل یک رویداد یادگیری معتبر در این نسخه ثبت کرده‌اند.', 'completions' => 'تعداد یادگیرندگان یکتایی که طبق قوانین Completion نسخه، دوره را تا پایان بازه تکمیل کرده‌اند.', 'averageProgress' => 'میانگین درصد پیشرفت جاری Assignmentهای یکتای نسخه. به‌دلیل نبود Snapshot تاریخی، تغییر دوره‌ای آن محاسبه نمی‌شود.', 'averageScore' => 'میانگین نمره تلاش‌های تکمیل‌شده ارزیابی در بازه، پس از تبدیل نمره صفر تا یک به درصد.', 'dropOff' => 'درصد شروع‌کنندگان یکتایی که تا پایان بازه دوره را تکمیل نکرده‌اند.', 'lessonViews' => 'تعداد رویدادهای مشاهده درس یا بلوک‌های آن در بازه انتخابی.', 'lessonTime' => 'مجموع زمان معتبر رویدادهای درس تقسیم بر تعداد یادگیرندگان فعال همان درس.', 'lessonCompletion' => 'نسبت یادگیرندگان یکتای تکمیل‌کننده درس به یادگیرندگان یکتای فعال در آن درس.', 'lessonScore' => 'میانگین امتیاز ثبت‌شده در رویدادهای ارزیابی همان درس؛ نبود امتیاز با «—» نمایش داده می‌شود.', ]; /** @return array */ public function build(CourseVersion $version, CarbonImmutable $from, CarbonImmutable $to, ?string $teamId, string $sort = 'position', string $direction = 'asc', int $page = 1, int $pageSize = 10): array { $previousTo = $from->subSecond(); $days = max(1, $from->diffInDays($to) + 1); $previousFrom = $previousTo->subDays($days - 1)->startOfDay(); $baseAssignments = DB::table('assignment_users as au') ->join('assignments as a', 'a.id', '=', 'au.assignment_id') ->where('a.organization_id', $version->organization_id) ->where('a.assignable_type', 'course') ->where('a.assignable_id', $version->getKey()) ->get(['au.user_id as userId', 'au.status', 'au.progress', 'au.assigned_at as assignedAt', 'au.completed_at as completedAt']); if ($teamId) { $teamUsers = DB::table('team_memberships')->where('team_id', $teamId)->pluck('user_id'); $baseAssignments = $baseAssignments->whereIn('userId', $teamUsers); } $assignments = $baseAssignments->filter(fn ($row) => CarbonImmutable::parse($row->assignedAt)->lte($to))->values(); $learnerIds = $assignments->pluck('userId')->unique()->values(); $allEvents = $learnerIds->isEmpty() ? collect() : LearningEvent::query() ->where('organization_id', $version->organization_id) ->where('course_version_id', $version->getKey()) ->whereIn('learner_id', $learnerIds) ->where('occurred_at', '<=', $to) ->orderBy('occurred_at') ->get(); $rangeEvents = $allEvents->filter(fn (LearningEvent $event) => $event->occurred_at->betweenIncluded($from, $to)); $firstActivity = $allEvents->groupBy('learner_id')->map(fn (Collection $rows) => $rows->min('occurred_at')); $completionDates = $assignments->groupBy('userId')->map(fn (Collection $rows) => $rows->pluck('completedAt')->filter()->min()); $current = $this->funnelAt($assignments, $firstActivity, $completionDates, $to); $previous = $this->funnelAt($baseAssignments, $firstActivity, $completionDates, $previousTo); $attempts = $learnerIds->isEmpty() ? collect() : DB::table('assessment_attempts') ->where('organization_id', $version->organization_id) ->where('course_version_id', $version->getKey()) ->where('status', 'completed') ->whereIn('learner_id', $learnerIds) ->whereBetween('completed_at', [$previousFrom, $to]) ->get(['learner_id', 'score', 'completed_at']); $currentScores = $attempts->filter(fn ($row) => CarbonImmutable::parse($row->completed_at)->betweenIncluded($from, $to)); $previousScores = $attempts->filter(fn ($row) => CarbonImmutable::parse($row->completed_at)->betweenIncluded($previousFrom, $previousTo)); $averageScore = $currentScores->isEmpty() ? null : round((float) $currentScores->avg('score') * 100, 1); $previousScore = $previousScores->isEmpty() ? null : round((float) $previousScores->avg('score') * 100, 1); $averageProgress = $assignments->isEmpty() ? null : round((float) $assignments->groupBy('userId')->map(fn (Collection $rows) => $rows->max('progress'))->avg(), 1); $trend = $this->trend($from, $to, $assignments, $firstActivity, $completionDates, $attempts); $metricSeries = [ 'learners' => array_column($trend, 'assigned'), 'starts' => array_column($trend, 'starts'), 'completions' => array_column($trend, 'completions'), 'averageProgress' => [], 'averageScore' => array_column($trend, 'averageScore'), 'dropOff' => array_column($trend, 'dropOff'), ]; $metrics = [ 'learners' => $this->metric($current['assigned'], $previous['assigned'], 'نفر', $metricSeries['learners']), 'starts' => $this->metric($current['started'], $previous['started'], 'نفر', $metricSeries['starts']), 'completions' => $this->metric($current['completed'], $previous['completed'], 'نفر', $metricSeries['completions']), 'averageProgress' => ['value' => $averageProgress, 'unit' => 'درصد', 'delta' => null, 'comparisonAvailable' => false, 'series' => []], 'averageScore' => $this->metric($averageScore, $previousScore, 'از ۱۰۰', $metricSeries['averageScore']), 'dropOff' => $this->metric($current['dropOff'], $previous['dropOff'], 'درصد', $metricSeries['dropOff']), ]; $content = $this->content($version, $rangeEvents); $content = $this->sortContent($content, $sort, $direction); $contentTotal = $content->count(); $lastPage = max(1, (int) ceil($contentTotal / $pageSize)); $page = min($page, $lastPage); $contentPage = $content->forPage($page, $pageSize)->values(); $teamPerformance = $this->teamPerformance($version, $to); $insights = $this->insights($version, $metrics, $content, $teamPerformance); return [ 'range' => ['from' => $from->toDateString(), 'to' => $to->toDateString(), 'previousFrom' => $previousFrom->toDateString(), 'previousTo' => $previousTo->toDateString()], 'definitions' => self::DEFINITIONS, 'metrics' => $metrics, 'funnel' => [ ['key' => 'assigned', 'label' => 'تخصیص‌یافته', 'value' => $current['assigned'], 'percentage' => $current['assigned'] > 0 ? 100 : null, 'conversionFromPrevious' => null], ['key' => 'started', 'label' => 'شروع‌کرده', 'value' => $current['started'], 'percentage' => $current['assigned'] > 0 ? round($current['started'] / $current['assigned'] * 100, 1) : null, 'conversionFromPrevious' => $current['assigned'] > 0 ? round($current['started'] / $current['assigned'] * 100, 1) : null], ['key' => 'completed', 'label' => 'تکمیل‌کرده', 'value' => $current['completed'], 'percentage' => $current['assigned'] > 0 ? round($current['completed'] / $current['assigned'] * 100, 1) : null, 'conversionFromPrevious' => $current['started'] > 0 ? round($current['completed'] / $current['started'] * 100, 1) : null], ], 'trend' => $trend, 'content' => ['items' => $contentPage->all(), 'meta' => ['page' => $page, 'pageSize' => $pageSize, 'total' => $contentTotal, 'lastPage' => $lastPage]], 'teams' => $teamPerformance, 'insights' => $insights, 'hasEvents' => $allEvents->isNotEmpty(), ]; } /** @return array{assigned: int, started: int, completed: int, dropOff: float|null} */ private function funnelAt(Collection $assignments, Collection $firstActivity, Collection $completionDates, CarbonImmutable $at): array { $assigned = $assignments->filter(fn ($row) => CarbonImmutable::parse($row->assignedAt)->lte($at))->pluck('userId')->unique(); $started = $assigned->filter(fn ($id) => ($date = $firstActivity->get($id)) && CarbonImmutable::parse($date)->lte($at)); $completed = $assigned->filter(fn ($id) => ($date = $completionDates->get($id)) && CarbonImmutable::parse($date)->lte($at)); return ['assigned' => $assigned->count(), 'started' => $started->count(), 'completed' => $completed->count(), 'dropOff' => $started->isEmpty() ? null : round((1 - min($started->count(), $completed->count()) / $started->count()) * 100, 1)]; } /** @return array */ private function metric(int|float|null $current, int|float|null $previous, string $unit, array $series): array { $comparable = $current !== null && $previous !== null && (float) $previous !== 0.0; return ['value' => $current, 'unit' => $unit, 'delta' => $comparable ? round(((float) $current - (float) $previous) / abs((float) $previous) * 100, 1) : null, 'comparisonAvailable' => $comparable, 'series' => $series]; } private function trend(CarbonImmutable $from, CarbonImmutable $to, Collection $assignments, Collection $firstActivity, Collection $completionDates, Collection $attempts): array { return collect(range(0, $from->diffInDays($to)))->map(function (int $offset) use ($from, $assignments, $firstActivity, $completionDates, $attempts) { $date = $from->addDays($offset)->endOfDay(); $funnel = $this->funnelAt($assignments, $firstActivity, $completionDates, $date); $scores = $attempts->filter(fn ($row) => CarbonImmutable::parse($row->completed_at)->betweenIncluded($from, $date)); return ['date' => $date->toDateString(), 'assigned' => $funnel['assigned'], 'starts' => $funnel['started'], 'completions' => $funnel['completed'], 'dropOff' => $funnel['dropOff'], 'averageScore' => $scores->isEmpty() ? null : round((float) $scores->avg('score') * 100, 1)]; })->all(); } private function content(CourseVersion $version, Collection $events): Collection { $lessons = DB::table('lessons as l')->join('course_modules as m', 'm.id', '=', 'l.course_module_id') ->where('l.course_version_id', $version->getKey())->orderBy('m.position')->orderBy('l.position') ->get(['l.id', 'l.title', 'l.position', 'm.title as moduleTitle']); return $lessons->map(function ($lesson) use ($events, $version) { $rows = $events->where('lesson_id', $lesson->id); $activeLearners = $rows->pluck('learner_id')->unique(); $views = $rows->whereIn('event_type', ['lesson.opened', 'block.viewed'])->count(); $completed = $rows->where('event_type', 'lesson.completed')->pluck('learner_id')->unique()->count(); $duration = $rows->sum(fn (LearningEvent $event) => min(21600, max(0, (int) ($event->payload['durationSeconds'] ?? 0)))); $scores = $rows->map(fn (LearningEvent $event) => $event->payload['score'] ?? null)->filter(fn ($score) => is_numeric($score)); $completionRate = $activeLearners->isEmpty() ? null : round($completed / $activeLearners->count() * 100, 1); $status = $completionRate === null ? 'no_data' : ($completionRate < 40 ? 'critical' : ($completionRate < 65 ? 'needs_review' : 'good')); return ['id' => $lesson->id, 'title' => $lesson->title, 'moduleTitle' => $lesson->moduleTitle, 'position' => (int) $lesson->position, 'views' => $views, 'averageTimeMinutes' => $activeLearners->isEmpty() ? null : round($duration / $activeLearners->count() / 60, 1), 'completionRate' => $completionRate, 'assessmentScore' => $scores->isEmpty() ? null : round((float) $scores->avg() * 100, 1), 'status' => $status, 'drilldown' => '/app/courses/'.$version->course_id.'/versions/'.$version->getKey().'/lessons/'.$lesson->id.'/builder']; }); } private function sortContent(Collection $content, string $sort, string $direction): Collection { $key = match ($sort) { 'title' => 'title', 'views' => 'views', 'time' => 'averageTimeMinutes', 'completion' => 'completionRate', 'score' => 'assessmentScore', 'status' => 'status', default => 'position', }; return ($direction === 'desc' ? $content->sortByDesc($key) : $content->sortBy($key))->values(); } private function teamPerformance(CourseVersion $version, CarbonImmutable $to): array { $rows = DB::table('teams as t')->leftJoin('team_memberships as tm', 'tm.team_id', '=', 't.id') ->where('t.organization_id', $version->organization_id)->orderBy('t.name')->get(['t.id', 't.name', 'tm.user_id']); $assignments = DB::table('assignment_users as au')->join('assignments as a', 'a.id', '=', 'au.assignment_id') ->where('a.organization_id', $version->organization_id)->where('a.assignable_type', 'course')->where('a.assignable_id', $version->getKey()) ->where('au.assigned_at', '<=', $to)->get(['au.user_id as userId', 'au.progress', 'au.completed_at as completedAt']); return $rows->groupBy('id')->map(function (Collection $members) use ($assignments, $to) { $memberIds = $members->pluck('user_id')->filter()->unique(); $teamAssignments = $assignments->whereIn('userId', $memberIds)->groupBy('userId'); $completed = $teamAssignments->filter(fn (Collection $items) => $items->contains(fn ($row) => $row->completedAt && CarbonImmutable::parse($row->completedAt)->lte($to)))->count(); $count = $teamAssignments->count(); return ['id' => $members->first()->id, 'name' => $members->first()->name, 'learners' => $count, 'completionRate' => $count > 0 ? round($completed / $count * 100, 1) : null, 'averageProgress' => $count > 0 ? round((float) $teamAssignments->map(fn (Collection $items) => $items->max('progress'))->avg(), 1) : null]; })->values()->all(); } private function insights(CourseVersion $version, array $metrics, Collection $content, array $teams): array { $items = []; $weakest = $content->whereNotNull('completionRate')->sortBy('completionRate')->first(); if ($weakest && $weakest['completionRate'] < 65) { $items[] = ['id' => 'weak-lesson-'.$weakest['id'], 'title' => 'درس «'.$weakest['title'].'» نیازمند بررسی است', 'description' => 'نرخ تکمیل این درس '.$weakest['completionRate'].'٪ است و از آستانه ۶۵٪ پایین‌تر قرار دارد.', 'severity' => $weakest['completionRate'] < 40 ? 'high' : 'medium', 'actionLabel' => 'بررسی درس', 'actionUrl' => $weakest['drilldown']]; } $weakTeam = collect($teams)->whereNotNull('completionRate')->sortBy('completionRate')->first(); if ($weakTeam && $weakTeam['completionRate'] < 60) { $items[] = ['id' => 'weak-team-'.$weakTeam['id'], 'title' => 'تیم «'.$weakTeam['name'].'» به مداخله نیاز دارد', 'description' => 'نرخ تکمیل این تیم '.$weakTeam['completionRate'].'٪ و میانگین پیشرفت آن '.($weakTeam['averageProgress'] ?? 0).'٪ است.', 'severity' => $weakTeam['completionRate'] < 35 ? 'high' : 'medium', 'actionLabel' => 'مشاهده تیم', 'actionUrl' => '/app/courses/'.$version->course_id.'?tab=learners&team='.$weakTeam['id']]; } if ($metrics['averageScore']['value'] !== null && $metrics['averageScore']['value'] < 70) { $items[] = ['id' => 'assessment-score', 'title' => 'میانگین امتیاز ارزیابی پایین است', 'description' => 'میانگین امتیاز ثبت‌شده '.$metrics['averageScore']['value'].' از ۱۰۰ است؛ پاسخ‌ها و سؤال‌های دشوار را بررسی کنید.', 'severity' => $metrics['averageScore']['value'] < 50 ? 'high' : 'medium', 'actionLabel' => 'مشاهده ارزیابی', 'actionUrl' => '/app/assessments?course='.$version->getKey()]; } if ($metrics['dropOff']['value'] !== null && $metrics['dropOff']['value'] > 30) { $items[] = ['id' => 'drop-off', 'title' => 'نرخ ریزش دوره بالاست', 'description' => $metrics['dropOff']['value'].'٪ از شروع‌کنندگان هنوز دوره را تکمیل نکرده‌اند.', 'severity' => $metrics['dropOff']['value'] > 50 ? 'high' : 'medium', 'actionLabel' => 'مقایسه نسخه‌ها', 'actionUrl' => '/app/courses/'.$version->course_id.'?tab=versions']; } return $items; } }