Files
saqel/backend/app/Services/StudentAccessControlService.php
T

341 lines
17 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 = true
): 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]
);
}
// إذا لم يكن مسجلاً، نتحقق مما إذا كان الدرس معاينة مجانية (Preview)
if (!$student) {
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' => 'guest',
'is_sponsored' => false
];
}
}
return [
'allowed' => false,
'reason' => 'unauthenticated_or_not_found',
'message' => 'يرجى تسجيل الدخول أو إدخال الرقم الوطني للتحقق من الصلاحية الدراسية.',
'student_grade' => 'unknown',
'is_sponsored' => false
];
}
$activeStudentGrade = self::normalizeGrade($student['grade_level'] ?? 'grade_10');
$normalizedTargetGrade = self::normalizeGrade($targetGrade);
// Auto-heal obsolete database schema default:
// If student was recorded with the old default 'tawjihi_2008' or empty and target is grade_10
if ($allowSideEffects && ($student['grade_level'] === 'tawjihi_2008' || empty($student['grade_level'])) && $normalizedTargetGrade === 'grade_10') {
Database::query("UPDATE students SET grade_level = 'grade_10', updated_at = NOW() WHERE id = ?", [(int)$student['id']]);
$student['grade_level'] = 'grade_10';
$activeStudentGrade = 'grade_10';
}
// 2. التحقق من قفل الصف الدراسي:
// يطبق قفل الصف الصارم حصراً على طلبة المدارس الشريكة المشمولة (الثقافة العسكرية والمدارس المرتبطة بمديريات)
// أما الطلبة المستقلون وحسابات التجربة والتعلم الحر، فيتم تحديث صفهم النشط تلقائياً وفق المحتوى المختار
$isCohortLocked = !empty($student['is_school_sponsored']) || !empty($student['school_id']);
if ($activeStudentGrade !== $normalizedTargetGrade) {
if (!$isCohortLocked && !empty($student['id'])) {
if ($allowSideEffects) {
Database::query("UPDATE students SET grade_level = ? WHERE id = ?", [$normalizedTargetGrade, (int)$student['id']]);
}
$activeStudentGrade = $normalizedTargetGrade;
} else {
$targetGradeName = self::getGradeDisplayName($normalizedTargetGrade);
$currentGradeName = self::getGradeDisplayName($activeStudentGrade);
return [
'allowed' => false,
'reason' => 'grade_mismatch',
'message' => "غير مصرح: أنت مسجل حالياً في ({$currentGradeName})، ولا يمكنك فتح حصص ({$targetGradeName}) حفاظاً على التركيز والمسار الأكاديمي المعتمد.",
'student_grade' => $activeStudentGrade,
'target_grade' => $normalizedTargetGrade,
'is_sponsored' => (bool)($student['is_school_sponsored'] ?? false)
];
}
}
// 3. التحقق من النموذج المالي المزدوج:
// أ. إذا كان الطالب تابعاً لمدارس الثقافة العسكرية أو مدرسة خاصة شريكة
$isMilitaryCulture = ($student['directorate_type'] ?? '') === 'military_culture' || ($student['school_type'] ?? '') === 'military_culture';
$isPrivateSponsored = !empty($student['is_school_sponsored']) && !empty($student['school_id']);
if ($isMilitaryCulture || $isPrivateSponsored) {
// الطالب مشمول مجاناً 100% (صفر دينار)
if ($allowSideEffects && $courseId && !empty($student['id'])) {
self::ensureSchoolIncludedPass((int)$student['id'], $courseId);
}
return [
'allowed' => true,
'reason' => 'school_sponsored_free',
'message' => 'مشمول مجاناً عبر المنظومة المؤسسية الشريكة (مديرية الثقافة العسكرية / المدرسة المعتمدة).',
'student_grade' => $activeStudentGrade,
'is_sponsored' => true,
'fee_jod' => 0.00
];
}
// ب. إذا كان طالباً مستقلاً خارج المدارس الشريكة (External Student)
if ($courseId) {
// المساقات المجانية (السعر 0.00 دينار) متاحة فوراً ومجاناً للجميع
$course = Database::selectOne("SELECT price_jod FROM courses WHERE id = ? LIMIT 1", [$courseId]);
if ($course && (float)($course['price_jod'] ?? 0) <= 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 (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']
];
}
// فحص إذا كان الدرس معاينة مجانية
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} طالباً في المدرسة بنجاح وتحديث صلاحياتهم الأكاديمية."
];
}
/**
* التحقق من وجود تصريح مدرسي مجاني أو إنشاؤه تلقائياً لطلبة المدارس الشريكة
*/
private static function ensureSchoolIncludedPass(int $studentId, int $courseId): void
{
$existing = Database::selectOne(
"SELECT id FROM course_access_passes WHERE student_id = ? AND course_id = ? LIMIT 1",
[$studentId, $courseId]
);
if (!$existing) {
$course = Database::selectOne("SELECT teacher_id FROM courses WHERE id = ? LIMIT 1", [$courseId]);
$teacherId = (int)($course['teacher_id'] ?? 1);
Database::insert(
"INSERT INTO course_access_passes
(student_id, course_id, teacher_id, pass_type, price_paid_jod, expires_at, is_active)
VALUES
(?, ?, ?, 'school_included', 0.00, DATE_ADD(NOW(), INTERVAL 1 YEAR), 1)",
[$studentId, $courseId, $teacherId]
);
}
}
/**
* توحيد أسماء ومفاتيح الصفوف
*/
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,
};
}
}