299 lines
15 KiB
PHP
299 lines
15 KiB
PHP
<?php
|
|
/**
|
|
* ==============================================================================
|
|
* SAQEL ENTERPRISE - STUDENT ACCESS CONTROL & GRADE PROGRESSION SERVICE
|
|
* ==============================================================================
|
|
*
|
|
* ملف: StudentAccessControlService.php
|
|
* الهدف المعماري:
|
|
* 1. حوكمة وصول الطالب للمحتوى بناءً على صفه الدراسي الحالي والمترفع له حصراً بواسطة الرقم الوطني.
|
|
* - إذا كان الطالب في الصف السادس، يُمنع تماماً من الوصول لحصص الصف الخامس (السابق) أو الصف السابع (اللاحق).
|
|
* 2. تطبيق النموذج المالي المزدوج:
|
|
* - طلبة المدارس الشريكة (مديرية الثقافة العسكرية أو المدارس الخاصة المسجلة): الوصول مجاني تماماً (0 دينار)
|
|
* عبر تصريح مدرسي مشمول (school_included).
|
|
* - الطلبة الخارجيون المستقلون: يلزمهم تصريح مدفوع عبر كليك (full_marketplace).
|
|
* 3. الترفيع السنوي الآلي (Annual Grade Promotion) وقفل السجلات القديمة.
|
|
*/
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Core\Database;
|
|
use App\Core\Security;
|
|
|
|
class StudentAccessControlService
|
|
{
|
|
/**
|
|
* التحقق الصارم من أهلية وصلاحية الطالب للوصول إلى درس معين
|
|
*
|
|
* @param int|null $studentId معرف الطالب في قاعدة البيانات
|
|
* @param string|null $nationalId الرقم الوطني الأردني
|
|
* @param string $targetGrade الصف الدراسي للدرس المطلوب (مثل grade_6, grade_10)
|
|
* @param int|null $courseId معرف الدورة
|
|
* @param int|null $lessonId معرف الدرس
|
|
* @return array [allowed => bool, reason => string, student_grade => string, is_sponsored => bool]
|
|
*/
|
|
public static function validateLessonAccess(
|
|
?int $studentId,
|
|
?string $nationalId,
|
|
string $targetGrade,
|
|
?int $courseId = null,
|
|
?int $lessonId = null,
|
|
bool $allowSideEffects = false
|
|
): array {
|
|
// 1. استرجاع بيانات الطالب وسجله الدراسي
|
|
$student = null;
|
|
if ($studentId) {
|
|
$student = Database::selectOne(
|
|
"SELECT s.*, sch.name as school_name, sch.type as school_type, sch.directorate_id,
|
|
d.type as directorate_type
|
|
FROM students s
|
|
LEFT JOIN schools sch ON s.school_id = sch.id
|
|
LEFT JOIN directorates d ON sch.directorate_id = d.id
|
|
WHERE s.id = ? LIMIT 1",
|
|
[$studentId]
|
|
);
|
|
} elseif (!empty($nationalId)) {
|
|
$student = Database::selectOne(
|
|
"SELECT s.*, sch.name as school_name, sch.type as school_type, sch.directorate_id,
|
|
d.type as directorate_type
|
|
FROM students s
|
|
LEFT JOIN schools sch ON s.school_id = sch.id
|
|
LEFT JOIN directorates d ON sch.directorate_id = d.id
|
|
WHERE s.national_id = ? LIMIT 1",
|
|
[$nationalId]
|
|
);
|
|
}
|
|
|
|
// Protected content requires a current student record. A preview flag
|
|
// must not turn a missing/deleted student into a valid entitlement.
|
|
if (!$student) {
|
|
return [
|
|
'allowed' => false,
|
|
'reason' => 'unauthenticated_or_not_found',
|
|
'message' => 'يرجى تسجيل الدخول أو إدخال الرقم الوطني للتحقق من الصلاحية الدراسية.',
|
|
'student_grade' => 'unknown',
|
|
'is_sponsored' => false
|
|
];
|
|
}
|
|
|
|
$activeStudentGrade = self::normalizeGrade((string)($student['grade_level'] ?? ''));
|
|
$normalizedTargetGrade = self::normalizeGrade($targetGrade);
|
|
if ($activeStudentGrade !== $normalizedTargetGrade) {
|
|
return [
|
|
'allowed' => false, 'reason' => 'grade_mismatch',
|
|
'message' => 'الدرس خارج صف الطالب المسجل؛ غيّر صفك من الملف المصرح أو راجع المدرسة.',
|
|
'student_grade' => $activeStudentGrade, 'target_grade' => $normalizedTargetGrade,
|
|
'is_sponsored' => (bool)($student['is_school_sponsored'] ?? false),
|
|
];
|
|
}
|
|
|
|
// A lesson read never changes grade or creates a school access pass.
|
|
// Entitlements are checked against an actual course and school roster.
|
|
if ($courseId) {
|
|
$course = Database::selectOne(
|
|
'SELECT c.id, c.school_id, c.is_school_exclusive, c.is_published, c.price_jod,
|
|
c.grade_level, t.is_school_exclusive AS teacher_school_exclusive,
|
|
t.is_marketplace_public
|
|
FROM courses c JOIN teachers t ON t.id=c.teacher_id WHERE c.id=? LIMIT 1',
|
|
[$courseId]
|
|
);
|
|
if (!$course || (int)$course['is_published'] !== 1) {
|
|
return ['allowed' => false, 'reason' => 'course_unavailable', 'student_grade' => $activeStudentGrade];
|
|
}
|
|
if ($course['grade_level'] !== null && $course['grade_level'] !== ''
|
|
&& self::normalizeGrade((string)$course['grade_level']) !== $normalizedTargetGrade) {
|
|
return ['allowed' => false, 'reason' => 'course_grade_mismatch', 'student_grade' => $activeStudentGrade];
|
|
}
|
|
if ($lessonId) {
|
|
$sourceLesson = Database::selectOne('SELECT course_id FROM lessons WHERE id=? LIMIT 1', [$lessonId]);
|
|
if (!$sourceLesson || (int)$sourceLesson['course_id'] !== $courseId) {
|
|
return ['allowed' => false, 'reason' => 'lesson_course_mismatch', 'student_grade' => $activeStudentGrade];
|
|
}
|
|
}
|
|
|
|
$schoolId = (int)($course['school_id'] ?? 0);
|
|
if ($schoolId > 0 || (int)$course['is_school_exclusive'] === 1 || (int)$course['teacher_school_exclusive'] === 1) {
|
|
if ($schoolId <= 0 || (int)($student['school_id'] ?? 0) !== $schoolId
|
|
|| empty($student['is_school_sponsored'])) {
|
|
return ['allowed' => false, 'reason' => 'school_scope_mismatch', 'student_grade' => $activeStudentGrade];
|
|
}
|
|
$roster = Database::selectOne(
|
|
'SELECT grade_level FROM school_rosters WHERE school_id=? AND claimed_student_id=? AND is_claimed=1 LIMIT 1',
|
|
[$schoolId, (int)$student['id']]
|
|
);
|
|
if (!$roster || self::normalizeGrade((string)$roster['grade_level']) !== $normalizedTargetGrade) {
|
|
return ['allowed' => false, 'reason' => 'school_roster_unverified', 'student_grade' => $activeStudentGrade];
|
|
}
|
|
return [
|
|
'allowed' => true, 'reason' => 'school_course_included',
|
|
'student_grade' => $activeStudentGrade, 'is_sponsored' => true,
|
|
];
|
|
}
|
|
|
|
if ((int)$course['is_marketplace_public'] !== 1) {
|
|
return ['allowed' => false, 'reason' => 'not_marketplace_public', 'student_grade' => $activeStudentGrade];
|
|
}
|
|
if ((float)$course['price_jod'] <= 0.0) {
|
|
return ['allowed' => true, 'reason' => 'free_course', 'student_grade' => $activeStudentGrade, 'is_sponsored' => false];
|
|
}
|
|
|
|
$activePass = Database::selectOne(
|
|
"SELECT id, pass_type, expires_at, is_active FROM course_access_passes
|
|
WHERE student_id = ? AND course_id = ? AND is_active = 1
|
|
AND pass_type IN ('discounted_micro_pass','full_marketplace')
|
|
AND (expires_at IS NULL OR expires_at > NOW())
|
|
LIMIT 1",
|
|
[(int)$student['id'], $courseId]
|
|
);
|
|
|
|
if ($activePass) {
|
|
return [
|
|
'allowed' => true,
|
|
'reason' => 'paid_pass_active',
|
|
'student_grade' => $activeStudentGrade,
|
|
'is_sponsored' => false,
|
|
'pass_id' => $activePass['id']
|
|
];
|
|
}
|
|
|
|
// An explicit free preview is allowed only on a public course.
|
|
if ($lessonId) {
|
|
$lesson = Database::selectOne("SELECT is_free_preview FROM lessons WHERE id = ? LIMIT 1", [$lessonId]);
|
|
if ($lesson && !empty($lesson['is_free_preview'])) {
|
|
return [
|
|
'allowed' => true,
|
|
'reason' => 'free_preview',
|
|
'student_grade' => $activeStudentGrade,
|
|
'is_sponsored' => false
|
|
];
|
|
}
|
|
}
|
|
|
|
return [
|
|
'allowed' => false,
|
|
'reason' => 'payment_required',
|
|
'message' => 'يتطلب هذا المساق تفعيل الاشتراك عبر نظام الدفع الفوري (CliQ) للطلبة المستقلين خارج المدارس الشريكة.',
|
|
'student_grade' => $activeStudentGrade,
|
|
'is_sponsored' => false,
|
|
'course_id' => $courseId
|
|
];
|
|
}
|
|
|
|
// افتراضياً لدروس المنهاج المعتمد لنفس الصف
|
|
return [
|
|
'allowed' => true,
|
|
'reason' => 'curriculum_default',
|
|
'student_grade' => $activeStudentGrade,
|
|
'is_sponsored' => false
|
|
];
|
|
}
|
|
|
|
/**
|
|
* ترفيع الطالب سنوياً إلى الصف اللاحق (مثال: من خامس إلى سادس)
|
|
* وإلغاء صلاحية الوصول لحصص الصفوف السابقة
|
|
*/
|
|
public static function promoteStudent(int $studentId, string $newGradeLevel): array
|
|
{
|
|
$student = Database::selectOne("SELECT id, full_name, grade_level, national_id FROM students WHERE id = ? LIMIT 1", [$studentId]);
|
|
if (!$student) {
|
|
return ['status' => 'error', 'message' => 'الطالب غير موجود'];
|
|
}
|
|
|
|
$oldGrade = self::normalizeGrade($student['grade_level']);
|
|
$promotedGrade = self::normalizeGrade($newGradeLevel);
|
|
|
|
Database::query("UPDATE students SET grade_level = ?, updated_at = NOW() WHERE id = ?", [$promotedGrade, $studentId]);
|
|
|
|
Database::query("UPDATE school_rosters SET grade_level = ? WHERE national_id = ?", [$promotedGrade, $student['national_id']]);
|
|
|
|
// إلغاء تفعيل التصاريح السابقة الخاصة بالصف المنتهي
|
|
Database::query(
|
|
"UPDATE course_access_passes cap
|
|
JOIN courses c ON cap.course_id = c.id
|
|
SET cap.is_active = 0
|
|
WHERE cap.student_id = ? AND c.grade_level = ?",
|
|
[$studentId, $oldGrade]
|
|
);
|
|
|
|
$oldName = self::getGradeDisplayName($oldGrade);
|
|
$newName = self::getGradeDisplayName($promotedGrade);
|
|
|
|
return [
|
|
'status' => 'success',
|
|
'student_id' => $studentId,
|
|
'student_name' => $student['full_name'],
|
|
'old_grade' => $oldGrade,
|
|
'promoted_grade' => $promotedGrade,
|
|
'message' => "تم ترفيع الطالب ({$student['full_name']}) بنجاح من ({$oldName}) إلى ({$newName}) وتحديث الصلاحيات."
|
|
];
|
|
}
|
|
|
|
/**
|
|
* ترفيع كشف المدرسة بالكامل دفعة واحدة (Batch Grade Promotion)
|
|
*/
|
|
public static function batchPromoteRoster(int $schoolId, string $fromGrade, string $toGrade): array
|
|
{
|
|
$from = self::normalizeGrade($fromGrade);
|
|
$to = self::normalizeGrade($toGrade);
|
|
|
|
$count = (int)Database::selectOne(
|
|
"SELECT COUNT(*) as cnt FROM students WHERE school_id = ? AND grade_level = ?",
|
|
[$schoolId, $from]
|
|
)['cnt'];
|
|
|
|
Database::query(
|
|
"UPDATE students SET grade_level = ?, updated_at = NOW() WHERE school_id = ? AND grade_level = ?",
|
|
[$to, $schoolId, $from]
|
|
);
|
|
|
|
Database::query(
|
|
"UPDATE school_rosters SET grade_level = ? WHERE school_id = ? AND grade_level = ?",
|
|
[$to, $schoolId, $from]
|
|
);
|
|
|
|
return [
|
|
'status' => 'success',
|
|
'school_id' => $schoolId,
|
|
'from_grade' => $from,
|
|
'to_grade' => $to,
|
|
'promoted_count' => $count,
|
|
'message' => "تم ترفيع {$count} طالباً في المدرسة بنجاح وتحديث صلاحياتهم الأكاديمية."
|
|
];
|
|
}
|
|
|
|
/**
|
|
* توحيد أسماء ومفاتيح الصفوف
|
|
*/
|
|
public static function normalizeGrade(string $grade): string
|
|
{
|
|
$g = strtolower(trim($grade));
|
|
if (str_contains($g, 'عاشر') || str_contains($g, 'grade_10') || str_contains($g, 'grade10') || str_contains($g, 'grade 10') || $g === '10') return 'grade_10';
|
|
if (str_contains($g, 'تاسع') || str_contains($g, 'grade_9') || str_contains($g, 'grade9') || str_contains($g, 'grade 9') || $g === '9') return 'grade_9';
|
|
if (str_contains($g, 'ثامن') || str_contains($g, 'grade_8') || str_contains($g, 'grade8') || str_contains($g, 'grade 8') || $g === '8') return 'grade_8';
|
|
if (str_contains($g, 'سابع') || str_contains($g, 'grade_7') || str_contains($g, 'grade7') || str_contains($g, 'grade 7') || $g === '7') return 'grade_7';
|
|
if (str_contains($g, 'سادس') || str_contains($g, 'grade_6') || str_contains($g, 'grade6') || str_contains($g, 'grade 6') || $g === '6') return 'grade_6';
|
|
if (str_contains($g, 'خامس') || str_contains($g, 'grade_5') || str_contains($g, 'grade5') || str_contains($g, 'grade 5') || $g === '5') return 'grade_5';
|
|
if (str_contains($g, 'حادي') || str_contains($g, 'أول ثانوي') || str_contains($g, 'grade_11') || str_contains($g, 'grade 11') || $g === '11') return 'grade_11';
|
|
if (str_contains($g, 'ثاني عشر') || str_contains($g, 'توجيهي') || str_contains($g, 'tawjihi') || str_contains($g, 'grade_12') || str_contains($g, 'grade 12') || $g === '12') return 'grade_12';
|
|
return $g;
|
|
}
|
|
|
|
/**
|
|
* اسم العرض للصف بالعربية
|
|
*/
|
|
public static function getGradeDisplayName(string $gradeKey): string
|
|
{
|
|
return match ($gradeKey) {
|
|
'grade_5' => 'الصف الخامس الأساسي',
|
|
'grade_6' => 'الصف السادس الأساسي',
|
|
'grade_7' => 'الصف السابع الأساسي',
|
|
'grade_8' => 'الصف الثامن الأساسي',
|
|
'grade_9' => 'الصف التاسع الأساسي',
|
|
'grade_10' => 'الصف العاشر الأساسي',
|
|
'grade_11' => 'الصف الحادي عشر (الأول ثانوي)',
|
|
'grade_12', 'tawjihi_2008' => 'الصف الثاني عشر (التوجيهي الوزاري)',
|
|
default => $gradeKey,
|
|
};
|
|
}
|
|
}
|