686 lines
30 KiB
PHP
686 lines
30 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\User;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Database\Query\Builder;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
final class ReportQueryService
|
|
{
|
|
/**
|
|
* @param array<string, mixed> $input
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function filters(array $input): array
|
|
{
|
|
$from = filled($input['date_from'] ?? null)
|
|
? CarbonImmutable::parse((string) $input['date_from'])->startOfDay()
|
|
: now()->subDays(6)->startOfDay()->toImmutable();
|
|
$to = filled($input['date_to'] ?? null)
|
|
? CarbonImmutable::parse((string) $input['date_to'])->endOfDay()
|
|
: now()->endOfDay()->toImmutable();
|
|
|
|
return [
|
|
'date_from' => $from,
|
|
'date_to' => $to,
|
|
'class_id' => $this->nullableInt($input['class_id'] ?? null),
|
|
'user_id' => $this->nullableInt($input['user_id'] ?? null),
|
|
'question_bank_id' => $this->nullableInt($input['question_bank_id'] ?? null),
|
|
'category_id' => $this->nullableInt($input['category_id'] ?? null),
|
|
'tag_id' => $this->nullableInt($input['tag_id'] ?? null),
|
|
'type' => filled($input['type'] ?? null) ? (string) $input['type'] : null,
|
|
'mode' => filled($input['mode'] ?? null) ? (string) $input['mode'] : null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function overview(User $user, array $filters): array
|
|
{
|
|
$answered = $this->answeredItems($user, $filters)
|
|
->selectRaw('count(*) as answered_count')
|
|
->selectRaw('count(distinct qa.id) as attempts')
|
|
->selectRaw('count(distinct qa.user_id) as active_students')
|
|
->selectRaw('coalesce(sum(case when qaq.is_correct = 1 then 1 else 0 end), 0) as correct_count')
|
|
->selectRaw('coalesce(avg(qaq.duration_seconds), 0) as avg_duration_seconds')
|
|
->first();
|
|
$totalItems = $this->allItems($user, $filters)->count();
|
|
$studentIds = $this->scopedStudentIds($user, $filters);
|
|
$wrongBase = $this->wrongQuestions($user, $filters);
|
|
$answeredCount = (int) ($answered->answered_count ?? 0);
|
|
$correctCount = (int) ($answered->correct_count ?? 0);
|
|
|
|
return [
|
|
'students' => $studentIds->count(),
|
|
'active_students' => (int) ($answered->active_students ?? 0),
|
|
'attempts' => (int) ($answered->attempts ?? 0),
|
|
'answered_count' => $answeredCount,
|
|
'accuracy' => $this->percent($correctCount, $answeredCount),
|
|
'completion_rate' => $this->percent($answeredCount, $totalItems),
|
|
'avg_duration_seconds' => round((float) ($answered->avg_duration_seconds ?? 0), 1),
|
|
'wrong_questions' => (clone $wrongBase)->whereNull('wrong_questions.mastered_at')->count(),
|
|
'mastered_wrong_questions' => (clone $wrongBase)->whereNotNull('wrong_questions.mastered_at')->count(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function trends(User $user, array $filters): array
|
|
{
|
|
$rows = $this->answeredItems($user, $filters)
|
|
->selectRaw('date(qaq.answered_at) as day')
|
|
->selectRaw('count(distinct qa.id) as attempts')
|
|
->selectRaw('count(distinct qa.user_id) as active_students')
|
|
->selectRaw('count(*) as answered_count')
|
|
->selectRaw('coalesce(sum(case when qaq.is_correct = 1 then 1 else 0 end), 0) as correct_count')
|
|
->selectRaw('coalesce(avg(qaq.duration_seconds), 0) as avg_duration_seconds')
|
|
->groupBy('day')
|
|
->orderBy('day')
|
|
->get()
|
|
->keyBy('day');
|
|
$wrongRows = $this->wrongQuestions($user, $filters)
|
|
->whereBetween('wrong_questions.last_wrong_at', [$filters['date_from'], $filters['date_to']])
|
|
->selectRaw('date(wrong_questions.last_wrong_at) as day, count(*) as wrong_added')
|
|
->groupBy('day')
|
|
->pluck('wrong_added', 'day');
|
|
|
|
$days = [];
|
|
$cursor = CarbonImmutable::parse($filters['date_from'])->startOfDay();
|
|
$end = CarbonImmutable::parse($filters['date_to'])->startOfDay();
|
|
while ($cursor <= $end) {
|
|
$day = $cursor->toDateString();
|
|
$row = $rows->get($day);
|
|
$answeredCount = (int) ($row->answered_count ?? 0);
|
|
$correctCount = (int) ($row->correct_count ?? 0);
|
|
$days[] = [
|
|
'day' => $day,
|
|
'attempts' => (int) ($row->attempts ?? 0),
|
|
'active_students' => (int) ($row->active_students ?? 0),
|
|
'answered_count' => $answeredCount,
|
|
'accuracy' => $this->percent($correctCount, $answeredCount),
|
|
'avg_duration_seconds' => round((float) ($row->avg_duration_seconds ?? 0), 1),
|
|
'wrong_added' => (int) ($wrongRows[$day] ?? 0),
|
|
];
|
|
$cursor = $cursor->addDay();
|
|
}
|
|
|
|
return $days;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
*/
|
|
public function questionErrors(User $user, array $filters, int $perPage = 20): LengthAwarePaginator
|
|
{
|
|
return $this->questionErrorQuery($user, $filters)
|
|
->paginate($perPage)
|
|
->through(fn (object $row): array => $this->questionErrorRow($row));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function classRanking(User $user, array $filters): array
|
|
{
|
|
return $this->visibleClasses($user, $filters)
|
|
->limit(20)
|
|
->get()
|
|
->map(function (object $class) use ($user, $filters): array {
|
|
$classFilters = $filters + ['class_id' => (int) $class->id];
|
|
$classFilters['class_id'] = (int) $class->id;
|
|
$overview = $this->overview($user, $classFilters);
|
|
|
|
return [
|
|
'id' => (int) $class->id,
|
|
'name' => $class->name,
|
|
'members_count' => (int) $class->members_count,
|
|
'attempts' => $overview['attempts'],
|
|
'answered_count' => $overview['answered_count'],
|
|
'completion_rate' => $overview['completion_rate'],
|
|
'avg_duration_seconds' => $overview['avg_duration_seconds'],
|
|
'accuracy' => $overview['accuracy'],
|
|
];
|
|
})
|
|
->sortByDesc('answered_count')
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function mastery(User $user, array $filters): array
|
|
{
|
|
return [
|
|
'banks' => $this->dimensionRows($user, $filters, 'qb.id', 'qb.name', ['qb.id', 'qb.name'], 20),
|
|
'categories' => $this->dimensionRows($user, $filters, 'q.category_id', 'coalesce(qc.name, "未分类")', ['q.category_id', 'qc.name', 'qb.name'], 30, [
|
|
'bank_name' => 'qb.name',
|
|
]),
|
|
'types' => $this->dimensionRows($user, $filters, 'q.type', 'q.type', ['q.type'], 10),
|
|
'tags' => $this->tagRows($user, $filters, 30),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
*/
|
|
public function students(User $user, array $filters, int $perPage = 20): LengthAwarePaginator
|
|
{
|
|
return $this->answeredItems($user, $filters)
|
|
->join('users as u', 'u.id', '=', 'qa.user_id')
|
|
->leftJoin('wrong_questions as wq', function ($join): void {
|
|
$join->on('wq.user_id', '=', 'u.id')
|
|
->on('wq.question_id', '=', 'q.id')
|
|
->whereNull('wq.mastered_at');
|
|
})
|
|
->selectRaw('u.id, u.name, u.email')
|
|
->selectRaw('count(distinct qa.id) as attempts')
|
|
->selectRaw('count(*) as answered_count')
|
|
->selectRaw('coalesce(sum(case when qaq.is_correct = 1 then 1 else 0 end), 0) as correct_count')
|
|
->selectRaw('coalesce(avg(qaq.duration_seconds), 0) as avg_duration_seconds')
|
|
->selectRaw('count(distinct wq.id) as wrong_questions')
|
|
->selectRaw('max(qaq.answered_at) as last_answered_at')
|
|
->groupBy('u.id', 'u.name', 'u.email')
|
|
->orderByDesc('answered_count')
|
|
->paginate($perPage)
|
|
->through(fn (object $row): array => $this->studentRow($row));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function studentDetail(User $viewer, User $student, array $filters): array
|
|
{
|
|
$filters['user_id'] = $student->id;
|
|
abort_if(! $this->canViewStudent($viewer, $student->id), 403);
|
|
|
|
return [
|
|
'student' => ['id' => $student->id, 'name' => $student->name, 'email' => $student->email],
|
|
'overview' => $this->overview($viewer, $filters),
|
|
'banks' => $this->dimensionRows($viewer, $filters, 'qb.id', 'qb.name', ['qb.id', 'qb.name'], 10),
|
|
'categories' => $this->dimensionRows($viewer, $filters, 'q.category_id', 'coalesce(qc.name, "未分类")', ['q.category_id', 'qc.name', 'qb.name'], 10, ['bank_name' => 'qb.name']),
|
|
'types' => $this->dimensionRows($viewer, $filters, 'q.type', 'q.type', ['q.type'], 10),
|
|
'question_errors' => $this->questionErrorQuery($viewer, $filters)->limit(10)->get()->map(fn (object $row): array => $this->questionErrorRow($row))->all(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function bankDetail(User $user, int $bankId, array $filters): array
|
|
{
|
|
$filters['question_bank_id'] = $bankId;
|
|
abort_if(! $this->canViewBank($user, $bankId), 403);
|
|
|
|
return [
|
|
'overview' => $this->overview($user, $filters),
|
|
'categories' => $this->dimensionRows($user, $filters, 'q.category_id', 'coalesce(qc.name, "未分类")', ['q.category_id', 'qc.name'], 20),
|
|
'tags' => $this->tagRows($user, $filters, 20),
|
|
'types' => $this->dimensionRows($user, $filters, 'q.type', 'q.type', ['q.type'], 10),
|
|
'question_errors' => $this->questionErrorQuery($user, $filters)->limit(20)->get()->map(fn (object $row): array => $this->questionErrorRow($row))->all(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function insights(User $user, array $filters): array
|
|
{
|
|
$students = $this->students($user, $filters, 100)->getCollection()
|
|
->filter(fn (array $row): bool => $row['accuracy'] < 60 && $row['answered_count'] >= 10)
|
|
->take(5)
|
|
->values()
|
|
->all();
|
|
$questions = $this->questionErrorQuery($user, $filters)
|
|
->havingRaw('wrong_count >= 3')
|
|
->limit(5)
|
|
->get()
|
|
->map(fn (object $row): array => $this->questionErrorRow($row))
|
|
->filter(fn (array $row): bool => $row['wrong_rate'] > 50 && $row['attempts'] >= 5)
|
|
->values()
|
|
->all();
|
|
$categories = collect($this->dimensionRows($user, $filters, 'q.category_id', 'coalesce(qc.name, "未分类")', ['q.category_id', 'qc.name', 'qb.name'], 30, ['bank_name' => 'qb.name']))
|
|
->filter(fn (array $row): bool => $row['accuracy'] < 65 && $row['answered_count'] >= 10)
|
|
->take(5)
|
|
->values()
|
|
->all();
|
|
|
|
return [
|
|
'students' => $students,
|
|
'questions' => $questions,
|
|
'categories' => $categories,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function options(User $user, array $filters): array
|
|
{
|
|
$bankQuery = DB::table('question_banks')
|
|
->select('id', 'name')
|
|
->when($user->role !== 'admin', fn (Builder $query) => $query->where('owner_id', $user->id))
|
|
->orderBy('name');
|
|
$classQuery = DB::table('classes')
|
|
->select('id', 'name')
|
|
->when($user->role !== 'admin', fn (Builder $query) => $query->where('owner_id', $user->id))
|
|
->orderBy('name');
|
|
$studentIds = $this->scopedStudentIds($user, $filters);
|
|
|
|
return [
|
|
'banks' => $bankQuery->get(),
|
|
'classes' => $classQuery->get(),
|
|
'students' => User::query()->select('id', 'name', 'email')->whereIn('id', $studentIds)->orderBy('name')->limit(200)->get(),
|
|
'categories' => DB::table('question_categories')
|
|
->join('question_banks', 'question_banks.id', '=', 'question_categories.question_bank_id')
|
|
->select('question_categories.id', 'question_categories.name', 'question_categories.question_bank_id')
|
|
->when($user->role !== 'admin', fn (Builder $query) => $query->where('question_banks.owner_id', $user->id))
|
|
->when($filters['question_bank_id'] ?? null, fn (Builder $query, int $bankId) => $query->where('question_categories.question_bank_id', $bankId))
|
|
->orderBy('question_categories.name')
|
|
->get(),
|
|
'tags' => DB::table('question_tags')
|
|
->join('question_banks', 'question_banks.id', '=', 'question_tags.question_bank_id')
|
|
->select('question_tags.id', 'question_tags.name', 'question_tags.question_bank_id')
|
|
->when($user->role !== 'admin', fn (Builder $query) => $query->where('question_banks.owner_id', $user->id))
|
|
->when($filters['question_bank_id'] ?? null, fn (Builder $query, int $bankId) => $query->where('question_tags.question_bank_id', $bankId))
|
|
->orderBy('question_tags.name')
|
|
->get(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function exportPayload(User $user, array $filters): array
|
|
{
|
|
return [
|
|
'filters' => [
|
|
'date_from' => CarbonImmutable::parse($filters['date_from'])->toDateString(),
|
|
'date_to' => CarbonImmutable::parse($filters['date_to'])->toDateString(),
|
|
] + collect($filters)->except(['date_from', 'date_to'])->filter()->all(),
|
|
'overview' => $this->overview($user, $filters),
|
|
'trends' => $this->trends($user, $filters),
|
|
'class_ranking' => $this->classRanking($user, $filters),
|
|
'students' => $this->students($user, $filters, 1000)->items(),
|
|
'mastery' => $this->mastery($user, $filters),
|
|
'question_errors' => $this->questionErrors($user, $filters, 1000)->items(),
|
|
'insights' => $this->insights($user, $filters),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
*/
|
|
private function answeredItems(User $user, array $filters): Builder
|
|
{
|
|
return $this->itemBase($user, $filters)
|
|
->whereNotNull('qaq.answered_at')
|
|
->whereBetween('qaq.answered_at', [$filters['date_from'], $filters['date_to']]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
*/
|
|
private function allItems(User $user, array $filters): Builder
|
|
{
|
|
return $this->itemBase($user, $filters)
|
|
->whereBetween('qa.started_at', [$filters['date_from'], $filters['date_to']]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
*/
|
|
private function itemBase(User $user, array $filters): Builder
|
|
{
|
|
return DB::table('quiz_attempt_questions as qaq')
|
|
->join('quiz_attempts as qa', 'qa.id', '=', 'qaq.quiz_attempt_id')
|
|
->join('questions as q', 'q.id', '=', 'qaq.question_id')
|
|
->join('question_banks as qb', 'qb.id', '=', 'q.question_bank_id')
|
|
->leftJoin('question_categories as qc', 'qc.id', '=', 'q.category_id')
|
|
->whereNull('q.deleted_at')
|
|
->whereNull('qb.deleted_at')
|
|
->tap(fn (Builder $query) => $this->applyScope($query, $user))
|
|
->tap(fn (Builder $query) => $this->applyFilters($query, $filters));
|
|
}
|
|
|
|
private function applyScope(Builder $query, User $user): void
|
|
{
|
|
if ($user->role === 'admin') {
|
|
return;
|
|
}
|
|
|
|
$query->where(function (Builder $scope) use ($user): void {
|
|
$scope->where('qb.owner_id', $user->id)
|
|
->orWhereExists(function (Builder $exists) use ($user): void {
|
|
$exists->selectRaw('1')
|
|
->from('class_members as scope_cm')
|
|
->join('classes as scope_c', 'scope_c.id', '=', 'scope_cm.class_id')
|
|
->whereColumn('scope_cm.user_id', 'qa.user_id')
|
|
->where('scope_c.owner_id', $user->id)
|
|
->whereNull('scope_c.deleted_at');
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
*/
|
|
private function applyFilters(Builder $query, array $filters): void
|
|
{
|
|
$query
|
|
->when($filters['class_id'] ?? null, fn (Builder $builder, int $classId) => $builder->whereExists(function (Builder $exists) use ($classId): void {
|
|
$exists->selectRaw('1')
|
|
->from('class_members as filter_cm')
|
|
->whereColumn('filter_cm.user_id', 'qa.user_id')
|
|
->where('filter_cm.class_id', $classId);
|
|
}))
|
|
->when($filters['user_id'] ?? null, fn (Builder $builder, int $userId) => $builder->where('qa.user_id', $userId))
|
|
->when($filters['question_bank_id'] ?? null, fn (Builder $builder, int $bankId) => $builder->where('q.question_bank_id', $bankId))
|
|
->when($filters['category_id'] ?? null, fn (Builder $builder, int $categoryId) => $builder->where('q.category_id', $categoryId))
|
|
->when($filters['tag_id'] ?? null, fn (Builder $builder, int $tagId) => $builder->whereExists(function (Builder $exists) use ($tagId): void {
|
|
$exists->selectRaw('1')
|
|
->from('question_tag as filter_qt')
|
|
->whereColumn('filter_qt.question_id', 'q.id')
|
|
->where('filter_qt.question_tag_id', $tagId);
|
|
}))
|
|
->when($filters['type'] ?? null, fn (Builder $builder, string $type) => $builder->where('q.type', $type))
|
|
->when($filters['mode'] ?? null, fn (Builder $builder, string $mode) => $builder->where('qa.mode', $mode));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
*/
|
|
private function wrongQuestions(User $user, array $filters): Builder
|
|
{
|
|
return DB::table('wrong_questions')
|
|
->join('questions as q', 'q.id', '=', 'wrong_questions.question_id')
|
|
->join('question_banks as qb', 'qb.id', '=', 'q.question_bank_id')
|
|
->leftJoin('question_categories as qc', 'qc.id', '=', 'q.category_id')
|
|
->whereNull('q.deleted_at')
|
|
->whereNull('qb.deleted_at')
|
|
->tap(fn (Builder $query) => $this->applyWrongScope($query, $user))
|
|
->tap(fn (Builder $query) => $this->applyWrongFilters($query, $filters));
|
|
}
|
|
|
|
private function applyWrongScope(Builder $query, User $user): void
|
|
{
|
|
if ($user->role === 'admin') {
|
|
return;
|
|
}
|
|
|
|
$query->where(function (Builder $scope) use ($user): void {
|
|
$scope->where('qb.owner_id', $user->id)
|
|
->orWhereExists(function (Builder $exists) use ($user): void {
|
|
$exists->selectRaw('1')
|
|
->from('class_members as scope_cm')
|
|
->join('classes as scope_c', 'scope_c.id', '=', 'scope_cm.class_id')
|
|
->whereColumn('scope_cm.user_id', 'wrong_questions.user_id')
|
|
->where('scope_c.owner_id', $user->id)
|
|
->whereNull('scope_c.deleted_at');
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
*/
|
|
private function applyWrongFilters(Builder $query, array $filters): void
|
|
{
|
|
$query
|
|
->when($filters['class_id'] ?? null, fn (Builder $builder, int $classId) => $builder->whereExists(function (Builder $exists) use ($classId): void {
|
|
$exists->selectRaw('1')
|
|
->from('class_members as filter_cm')
|
|
->whereColumn('filter_cm.user_id', 'wrong_questions.user_id')
|
|
->where('filter_cm.class_id', $classId);
|
|
}))
|
|
->when($filters['user_id'] ?? null, fn (Builder $builder, int $userId) => $builder->where('wrong_questions.user_id', $userId))
|
|
->when($filters['question_bank_id'] ?? null, fn (Builder $builder, int $bankId) => $builder->where('q.question_bank_id', $bankId))
|
|
->when($filters['category_id'] ?? null, fn (Builder $builder, int $categoryId) => $builder->where('q.category_id', $categoryId))
|
|
->when($filters['tag_id'] ?? null, fn (Builder $builder, int $tagId) => $builder->whereExists(function (Builder $exists) use ($tagId): void {
|
|
$exists->selectRaw('1')
|
|
->from('question_tag as filter_qt')
|
|
->whereColumn('filter_qt.question_id', 'q.id')
|
|
->where('filter_qt.question_tag_id', $tagId);
|
|
}))
|
|
->when($filters['type'] ?? null, fn (Builder $builder, string $type) => $builder->where('q.type', $type));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
*/
|
|
private function questionErrorQuery(User $user, array $filters): Builder
|
|
{
|
|
return $this->answeredItems($user, $filters)
|
|
->selectRaw('q.id, q.content, q.type, qb.name as bank_name, coalesce(qc.name, "未分类") as category_name')
|
|
->selectRaw('count(*) as attempts')
|
|
->selectRaw('coalesce(sum(case when qaq.is_correct = 0 then 1 else 0 end), 0) as wrong_count')
|
|
->selectRaw('max(case when qaq.is_correct = 0 then qaq.answered_at else null end) as last_wrong_at')
|
|
->groupBy('q.id', 'q.content', 'q.type', 'qb.name', 'qc.name')
|
|
->orderByDesc('wrong_count')
|
|
->orderByDesc('attempts');
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
* @param array<int, string> $groups
|
|
* @param array<string, string> $extraSelects
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
private function dimensionRows(User $user, array $filters, string $idExpression, string $nameExpression, array $groups, int $limit, array $extraSelects = []): array
|
|
{
|
|
$query = $this->answeredItems($user, $filters)
|
|
->selectRaw($idExpression.' as id')
|
|
->selectRaw($nameExpression.' as name');
|
|
foreach ($extraSelects as $alias => $expression) {
|
|
$query->selectRaw($expression.' as '.$alias);
|
|
}
|
|
|
|
return $query
|
|
->selectRaw('count(*) as answered_count')
|
|
->selectRaw('coalesce(sum(case when qaq.is_correct = 1 then 1 else 0 end), 0) as correct_count')
|
|
->selectRaw('coalesce(sum(case when qaq.is_correct = 0 then 1 else 0 end), 0) as wrong_count')
|
|
->selectRaw('coalesce(avg(qaq.duration_seconds), 0) as avg_duration_seconds')
|
|
->groupBy(...$groups)
|
|
->orderByDesc('wrong_count')
|
|
->orderByDesc('answered_count')
|
|
->limit($limit)
|
|
->get()
|
|
->map(fn (object $row): array => $this->dimensionRow($row))
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
private function tagRows(User $user, array $filters, int $limit): array
|
|
{
|
|
return $this->answeredItems($user, $filters)
|
|
->join('question_tag as qt', 'qt.question_id', '=', 'q.id')
|
|
->join('question_tags as qtag', 'qtag.id', '=', 'qt.question_tag_id')
|
|
->selectRaw('qtag.id as id, qtag.name as name, qb.name as bank_name')
|
|
->selectRaw('count(*) as answered_count')
|
|
->selectRaw('coalesce(sum(case when qaq.is_correct = 1 then 1 else 0 end), 0) as correct_count')
|
|
->selectRaw('coalesce(sum(case when qaq.is_correct = 0 then 1 else 0 end), 0) as wrong_count')
|
|
->selectRaw('coalesce(avg(qaq.duration_seconds), 0) as avg_duration_seconds')
|
|
->groupBy('qtag.id', 'qtag.name', 'qb.name')
|
|
->orderByDesc('wrong_count')
|
|
->orderByDesc('answered_count')
|
|
->limit($limit)
|
|
->get()
|
|
->map(fn (object $row): array => $this->dimensionRow($row))
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
*/
|
|
private function visibleClasses(User $user, array $filters): Builder
|
|
{
|
|
return DB::table('classes')
|
|
->leftJoin('class_members', 'class_members.class_id', '=', 'classes.id')
|
|
->whereNull('classes.deleted_at')
|
|
->when($user->role !== 'admin', fn (Builder $query) => $query->where('classes.owner_id', $user->id))
|
|
->when($filters['class_id'] ?? null, fn (Builder $query, int $classId) => $query->where('classes.id', $classId))
|
|
->selectRaw('classes.id, classes.name, count(distinct class_members.user_id) as members_count')
|
|
->groupBy('classes.id', 'classes.name')
|
|
->orderBy('classes.name');
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
* @return Collection<int, int>
|
|
*/
|
|
private function scopedStudentIds(User $user, array $filters): Collection
|
|
{
|
|
$ids = DB::table('quiz_attempts as qa')
|
|
->join('quiz_attempt_questions as qaq', 'qaq.quiz_attempt_id', '=', 'qa.id')
|
|
->join('questions as q', 'q.id', '=', 'qaq.question_id')
|
|
->join('question_banks as qb', 'qb.id', '=', 'q.question_bank_id')
|
|
->whereNotNull('qaq.answered_at')
|
|
->tap(fn (Builder $query) => $this->applyScope($query, $user))
|
|
->tap(fn (Builder $query) => $this->applyFilters($query, $filters))
|
|
->distinct()
|
|
->pluck('qa.user_id');
|
|
|
|
$classMemberQuery = DB::table('class_members')
|
|
->join('classes', 'classes.id', '=', 'class_members.class_id')
|
|
->whereNull('classes.deleted_at')
|
|
->when($user->role !== 'admin', fn (Builder $query) => $query->where('classes.owner_id', $user->id))
|
|
->when($filters['class_id'] ?? null, fn (Builder $query, int $classId) => $query->where('class_members.class_id', $classId));
|
|
|
|
if (($filters['class_id'] ?? null) || $user->role !== 'admin') {
|
|
$classIds = $classMemberQuery
|
|
->pluck('user_id');
|
|
$ids = $ids->merge($classIds);
|
|
}
|
|
|
|
return $ids->map(fn ($id): int => (int) $id)->unique()->values();
|
|
}
|
|
|
|
private function canViewStudent(User $viewer, int $studentId): bool
|
|
{
|
|
if ($viewer->role === 'admin') {
|
|
return true;
|
|
}
|
|
|
|
return DB::table('class_members')
|
|
->join('classes', 'classes.id', '=', 'class_members.class_id')
|
|
->where('class_members.user_id', $studentId)
|
|
->where('classes.owner_id', $viewer->id)
|
|
->whereNull('classes.deleted_at')
|
|
->exists();
|
|
}
|
|
|
|
private function canViewBank(User $viewer, int $bankId): bool
|
|
{
|
|
if ($viewer->role === 'admin') {
|
|
return true;
|
|
}
|
|
|
|
return DB::table('question_banks')
|
|
->where('id', $bankId)
|
|
->where('owner_id', $viewer->id)
|
|
->whereNull('deleted_at')
|
|
->exists();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function dimensionRow(object $row): array
|
|
{
|
|
$answered = (int) $row->answered_count;
|
|
$correct = (int) $row->correct_count;
|
|
$data = [
|
|
'id' => $row->id === null ? null : (is_numeric($row->id) ? (int) $row->id : (string) $row->id),
|
|
'name' => (string) $row->name,
|
|
'answered_count' => $answered,
|
|
'correct_count' => $correct,
|
|
'wrong_count' => (int) $row->wrong_count,
|
|
'accuracy' => $this->percent($correct, $answered),
|
|
'avg_duration_seconds' => round((float) $row->avg_duration_seconds, 1),
|
|
];
|
|
|
|
if (property_exists($row, 'bank_name')) {
|
|
$data['bank_name'] = $row->bank_name;
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function questionErrorRow(object $row): array
|
|
{
|
|
$attempts = (int) $row->attempts;
|
|
$wrong = (int) $row->wrong_count;
|
|
|
|
return [
|
|
'id' => (int) $row->id,
|
|
'content' => $row->content,
|
|
'type' => $row->type,
|
|
'bank_name' => $row->bank_name,
|
|
'category_name' => $row->category_name,
|
|
'attempts' => $attempts,
|
|
'wrong_count' => $wrong,
|
|
'wrong_rate' => $this->percent($wrong, $attempts),
|
|
'last_wrong_at' => $row->last_wrong_at,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function studentRow(object $row): array
|
|
{
|
|
$answered = (int) $row->answered_count;
|
|
$correct = (int) $row->correct_count;
|
|
|
|
return [
|
|
'id' => (int) $row->id,
|
|
'name' => $row->name,
|
|
'email' => $row->email,
|
|
'attempts' => (int) $row->attempts,
|
|
'answered_count' => $answered,
|
|
'correct_count' => $correct,
|
|
'accuracy' => $this->percent($correct, $answered),
|
|
'avg_duration_seconds' => round((float) $row->avg_duration_seconds, 1),
|
|
'wrong_questions' => (int) $row->wrong_questions,
|
|
'last_answered_at' => $row->last_answered_at,
|
|
];
|
|
}
|
|
|
|
private function percent(int|float $value, int|float $total): float
|
|
{
|
|
return $total > 0 ? round($value / $total * 100, 2) : 0.0;
|
|
}
|
|
|
|
private function nullableInt(mixed $value): ?int
|
|
{
|
|
if ($value === null || $value === '') {
|
|
return null;
|
|
}
|
|
|
|
return (int) $value;
|
|
}
|
|
}
|