161 خطوط
10 KiB
PHP
161 خطوط
10 KiB
PHP
<?php
|
|
|
|
namespace App\Modules\Learner\Application;
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Analytics\Jobs\ProcessLearningEvent;
|
|
use App\Modules\Assignments\Domain\Assignment;
|
|
use App\Modules\Courses\Domain\Block;
|
|
use App\Modules\Courses\Domain\CourseVersion;
|
|
use App\Modules\Courses\Domain\Lesson;
|
|
use App\Modules\Learner\Domain\BlockProgress;
|
|
use App\Modules\Learner\Domain\LearnerNote;
|
|
use App\Modules\Learner\Domain\LearningEvent;
|
|
use App\Modules\Learner\Domain\LessonProgress;
|
|
use App\Modules\LearningPaths\Domain\LearningPathVersion;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
final class LearnerProgressService
|
|
{
|
|
public function assignment(User $learner, string $assignmentId): Assignment
|
|
{
|
|
return Assignment::query()->where('organization_id', $learner->organization_id)->where('status', 'active')
|
|
->whereHas('users', fn ($query) => $query->where('users.id', $learner->getKey())->whereNot('assignment_users.status', 'cancelled'))
|
|
->findOrFail($assignmentId);
|
|
}
|
|
|
|
public function courseVersion(Assignment $assignment, string $courseVersionId): CourseVersion
|
|
{
|
|
$allowed = $assignment->assignable_type === 'course' && (string) $assignment->assignable_id === $courseVersionId;
|
|
if ($assignment->assignable_type === 'learning_path') {
|
|
$allowed = LearningPathVersion::query()->whereKey($assignment->assignable_id)->whereHas('items', fn ($query) => $query->where('course_version_id', $courseVersionId))->exists();
|
|
}
|
|
if (! $allowed) {
|
|
throw ValidationException::withMessages(['courseVersionId' => ['این دوره بخشی از تخصیص شما نیست.']]);
|
|
}
|
|
|
|
return CourseVersion::query()->where('organization_id', $assignment->organization_id)->whereKey($courseVersionId)->firstOrFail();
|
|
}
|
|
|
|
/** @param array<string, mixed> $payload */
|
|
public function record(User $learner, Assignment $assignment, CourseVersion $version, string $eventId, string $type, ?Lesson $lesson, ?Block $block, array $payload, \DateTimeInterface $occurredAt, array $context = []): array
|
|
{
|
|
if ($type === 'block.interacted' && $block) {
|
|
$payload['score'] = $this->assessmentScore($block, $payload['response'] ?? null);
|
|
}
|
|
|
|
return DB::transaction(function () use ($learner, $assignment, $version, $eventId, $type, $lesson, $block, $payload, $occurredAt, $context): array {
|
|
$event = LearningEvent::query()->firstOrCreate(
|
|
['learner_id' => $learner->getKey(), 'client_event_id' => $eventId],
|
|
['organization_id' => $learner->organization_id, 'event_type' => $type, 'schema_version' => $context['schemaVersion'] ?? 1, 'session_id' => $context['sessionId'] ?? null, 'correlation_id' => $context['correlationId'] ?? $eventId, 'causation_id' => $context['causationId'] ?? null, 'device_context' => $context['deviceContext'] ?? null, 'assignment_id' => $assignment->getKey(), 'course_version_id' => $version->getKey(), 'lesson_id' => $lesson?->getKey(), 'block_id' => $block?->getKey(), 'payload' => $payload, 'occurred_at' => $occurredAt, 'received_at' => now()],
|
|
);
|
|
if (! $event->wasRecentlyCreated) {
|
|
return ['duplicate' => true, ...$this->courseProgress($learner, $assignment, $version)];
|
|
}
|
|
if ($type === 'note.created' && $lesson && trim((string) ($payload['body'] ?? '')) !== '') {
|
|
LearnerNote::query()->create([
|
|
'organization_id' => $learner->organization_id,
|
|
'learner_id' => $learner->getKey(),
|
|
'course_version_id' => $version->getKey(),
|
|
'lesson_id' => $lesson->getKey(),
|
|
'body' => trim((string) $payload['body']),
|
|
]);
|
|
}
|
|
if ($lesson) {
|
|
$lessonProgress = LessonProgress::query()->firstOrCreate(
|
|
['assignment_id' => $assignment->getKey(), 'learner_id' => $learner->getKey(), 'lesson_id' => $lesson->getKey()],
|
|
['organization_id' => $learner->organization_id, 'course_version_id' => $version->getKey(), 'status' => 'in_progress', 'progress' => 0, 'started_at' => $occurredAt],
|
|
);
|
|
$lessonProgress->update(['last_activity_at' => $occurredAt]);
|
|
if ($block) {
|
|
BlockProgress::query()->updateOrCreate(
|
|
['assignment_id' => $assignment->getKey(), 'learner_id' => $learner->getKey(), 'block_id' => $block->getKey()],
|
|
['organization_id' => $learner->organization_id, 'lesson_id' => $lesson->getKey(), 'status' => in_array($type, ['block.completed', 'block.interacted'], true) ? 'completed' : 'viewed', 'score' => $payload['score'] ?? null, 'response' => $payload['response'] ?? null, 'first_viewed_at' => $occurredAt, 'completed_at' => in_array($type, ['block.completed', 'block.interacted'], true) ? $occurredAt : null],
|
|
);
|
|
}
|
|
$total = $lesson->blocks()->count();
|
|
$completed = BlockProgress::query()->where('assignment_id', $assignment->getKey())->where('learner_id', $learner->getKey())->where('lesson_id', $lesson->getKey())->where('status', 'completed')->count();
|
|
$progress = $total > 0 ? (int) floor(($completed / $total) * 100) : 0;
|
|
if ($type === 'lesson.completed') {
|
|
$progress = 100;
|
|
}
|
|
$lessonProgress->update(['progress' => $progress, 'status' => $progress === 100 ? 'completed' : 'in_progress', 'completed_at' => $progress === 100 ? $occurredAt : null]);
|
|
}
|
|
$summary = $this->courseProgress($learner, $assignment, $version);
|
|
DB::table('assignment_users')->where('assignment_id', $assignment->getKey())->where('user_id', $learner->getKey())->update(['progress' => $summary['progress'], 'status' => $summary['completed'] ? 'completed' : 'in_progress', 'completed_at' => $summary['completed'] ? now() : null, 'updated_at' => now()]);
|
|
ProcessLearningEvent::dispatch($event->getKey())->afterCommit();
|
|
|
|
return ['duplicate' => false, ...$summary];
|
|
});
|
|
}
|
|
|
|
/** @return array{progress: int, completed: bool, completedLessons: int, totalLessons: int} */
|
|
public function courseProgress(User $learner, Assignment $assignment, CourseVersion $version): array
|
|
{
|
|
$total = $version->lessons()->count();
|
|
$completed = LessonProgress::query()->where('assignment_id', $assignment->getKey())->where('learner_id', $learner->getKey())->where('course_version_id', $version->getKey())->where('status', 'completed')->count();
|
|
$percentage = $total > 0 ? (int) floor(($completed / $total) * 100) : 0;
|
|
$rules = $version->completion_rules ?? ['mode' => 'all', 'rules' => [['type' => 'all_required_lessons']]];
|
|
$results = collect($rules['rules'] ?? [])->map(fn (array $rule) => match ($rule['type'] ?? '') {
|
|
'all_required_lessons' => $total > 0 && $completed === $total,
|
|
'minimum_lesson_percentage' => $percentage >= (int) ($rule['value'] ?? 100),
|
|
'minimum_score', 'assessment_passed' => BlockProgress::query()->where('assignment_id', $assignment->getKey())->where('learner_id', $learner->getKey())->whereNotNull('score')->where('score', '>=', (float) ($rule['value'] ?? 0.7))->exists(),
|
|
'required_interaction' => BlockProgress::query()->where('assignment_id', $assignment->getKey())->where('learner_id', $learner->getKey())->where('status', 'completed')->exists(),
|
|
default => false,
|
|
});
|
|
$isComplete = $results->isNotEmpty() && (($rules['mode'] ?? 'all') === 'any' ? $results->contains(true) : $results->every(fn ($value) => $value));
|
|
|
|
return ['progress' => $isComplete ? 100 : $percentage, 'completed' => $isComplete, 'completedLessons' => $completed, 'totalLessons' => $total];
|
|
}
|
|
|
|
private function assessmentScore(Block $block, mixed $response): ?float
|
|
{
|
|
$data = $block->data ?? [];
|
|
$answers = is_array($response) ? $response : [];
|
|
|
|
return match ($block->type) {
|
|
'single_choice' => ((int) ($answers[0] ?? -1)) === (int) ($data['answerIndex'] ?? -2) ? 1.0 : 0.0,
|
|
'multiple_choice' => $this->sameValues($answers, $data['answerIndexes'] ?? []) ? 1.0 : 0.0,
|
|
'true_false' => (((int) ($answers[0] ?? -1)) === 0) === (bool) ($data['answer'] ?? false) ? 1.0 : 0.0,
|
|
'scenario' => max(0.0, min(1.0, (float) ($data['choices'][(int) ($answers[0] ?? -1)]['score'] ?? 0))),
|
|
'matching' => $this->sameSequence($answers, array_column($data['pairs'] ?? [], 'right')) ? 1.0 : 0.0,
|
|
'sorting' => $this->sameSequence($answers, array_column($data['items'] ?? [], 'text')) ? 1.0 : 0.0,
|
|
'drag_drop' => $this->sameSequence($answers, array_column($data['items'] ?? [], 'target')) ? 1.0 : 0.0,
|
|
'hotspot' => $this->hotspotScore($answers, $data['hotspots'] ?? []),
|
|
'branching_scenario' => ($answers['completed'] ?? false) === true ? 1.0 : 0.0,
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
/** @param array<int, mixed> $actual @param array<int, mixed> $expected */
|
|
private function sameValues(array $actual, array $expected): bool
|
|
{
|
|
sort($actual);
|
|
sort($expected);
|
|
|
|
return $actual === $expected;
|
|
}
|
|
|
|
/** @param array<int, mixed> $actual @param array<int, mixed> $expected */
|
|
private function sameSequence(array $actual, array $expected): bool
|
|
{
|
|
return array_values($actual) === array_values($expected);
|
|
}
|
|
|
|
/** @param array<string, mixed> $answer @param array<int, array<string, mixed>> $hotspots */
|
|
private function hotspotScore(array $answer, array $hotspots): float
|
|
{
|
|
$x = (float) ($answer['x'] ?? -1000);
|
|
$y = (float) ($answer['y'] ?? -1000);
|
|
|
|
foreach ($hotspots as $hotspot) {
|
|
if (($hotspot['correct'] ?? false) && hypot($x - (float) $hotspot['x'], $y - (float) $hotspot['y']) <= (float) $hotspot['radius']) {
|
|
return 1.0;
|
|
}
|
|
}
|
|
|
|
return 0.0;
|
|
}
|
|
}
|