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 $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 $actual @param array $expected */ private function sameValues(array $actual, array $expected): bool { sort($actual); sort($expected); return $actual === $expected; } /** @param array $actual @param array $expected */ private function sameSequence(array $actual, array $expected): bool { return array_values($actual) === array_values($expected); } /** @param array $answer @param array> $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; } }