New_Micro_Learning/backend/app/Modules/Learner/Application/LearnerProgressService.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;
}
}