New_Micro_Learning/backend/app/Modules/Analytics/Application/CourseAnalyticsDashboard.php

211 خطوط
17 KiB
PHP
خام سرزنش تاریخچه

مخزن ambiguous runes header

راهنمای مخزن ambiguous runes توضیحات

<?php
namespace App\Modules\Analytics\Application;
use App\Modules\Courses\Domain\CourseVersion;
use App\Modules\Learner\Domain\LearningEvent;
use Carbon\CarbonImmutable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
final class CourseAnalyticsDashboard
{
public const DEFINITIONS = [
'learners' => 'تعداد یادگیرندگان یکتایی که تا پایان بازه، نسخه انتخابی به آن‌ها تخصیص یافته است.',
'starts' => 'تعداد یادگیرندگان تخصیص‌یافته‌ای که تا پایان بازه حداقل یک رویداد یادگیری معتبر در این نسخه ثبت کرده‌اند.',
'completions' => 'تعداد یادگیرندگان یکتایی که طبق قوانین Completion نسخه، دوره را تا پایان بازه تکمیل کرده‌اند.',
'averageProgress' => 'میانگین درصد پیشرفت جاری Assignmentهای یکتای نسخه. به‌دلیل نبود Snapshot تاریخی، تغییر دوره‌ای آن محاسبه نمی‌شود.',
'averageScore' => 'میانگین نمره تلاش‌های تکمیل‌شده ارزیابی در بازه، پس از تبدیل نمره صفر تا یک به درصد.',
'dropOff' => 'درصد شروع‌کنندگان یکتایی که تا پایان بازه دوره را تکمیل نکرده‌اند.',
'lessonViews' => 'تعداد رویدادهای مشاهده درس یا بلوک‌های آن در بازه انتخابی.',
'lessonTime' => 'مجموع زمان معتبر رویدادهای درس تقسیم بر تعداد یادگیرندگان فعال همان درس.',
'lessonCompletion' => 'نسبت یادگیرندگان یکتای تکمیل‌کننده درس به یادگیرندگان یکتای فعال در آن درس.',
'lessonScore' => 'میانگین امتیاز ثبت‌شده در رویدادهای ارزیابی همان درس؛ نبود امتیاز با «—» نمایش داده می‌شود.',
];
/** @return array<string, mixed> */
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<string, mixed> */
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;
}
}