Update Saqel Platform: 2026-09-08 13:43:36
This commit is contained in:
@@ -2,451 +2,288 @@
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Database;
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
use App\Core\Database;
|
||||
use App\Core\Security;
|
||||
use App\Services\VideoService;
|
||||
|
||||
class DirectorateSupervisorController
|
||||
{
|
||||
/**
|
||||
* Get Directorate Macro Dashboard (لوحة القائد لمديرية الثقافة العسكرية)
|
||||
* GET /api/directorate/dashboard
|
||||
*/
|
||||
public function getDirectorateDashboard(Request $request, Response $response): void
|
||||
{
|
||||
$directorateCode = $request->getQueryParams()['directorate_code'] ?? 'MC-JOR';
|
||||
|
||||
// Fetch or default Military Culture Directorate
|
||||
$directorate = [
|
||||
'code' => 'MC-JOR',
|
||||
'name' => 'مديرية التربية والتعليم والثقافة العسكرية',
|
||||
'type' => 'military_culture',
|
||||
'commander_name' => 'مدير التعليم والثقافة العسكرية',
|
||||
'total_schools' => 43,
|
||||
'total_students' => 19350,
|
||||
'total_teachers' => 812,
|
||||
'annual_contract_value' => 20000.00,
|
||||
'recorded_lessons_month'=> 1420,
|
||||
'compliance_rate' => 94.5, // % of bi-weekly video quotas met
|
||||
'ai_average_score' => 91.8,
|
||||
'active_unified_exams' => 2,
|
||||
];
|
||||
|
||||
// Sample list of 43 schools categorized by weight tiers across Jordan
|
||||
$sampleSchools = [
|
||||
[
|
||||
'id' => 1,
|
||||
'code' => 'SCH-MC-01',
|
||||
'name' => 'مدرسة الشهيد فيصل الثاني الثانوية العسكرية',
|
||||
'governorate' => 'العاصمة',
|
||||
'weight_tier' => 'tier_c_large',
|
||||
'student_count'=> 980,
|
||||
'teacher_count'=> 48,
|
||||
'compliance' => 98.0,
|
||||
'status' => 'excellent',
|
||||
'annual_fee' => 900.00,
|
||||
],
|
||||
[
|
||||
'id' => 2,
|
||||
'code' => 'SCH-MC-02',
|
||||
'name' => 'مدرسة الملك حسين الثانوية العسكرية',
|
||||
'governorate' => 'الزرقاء',
|
||||
'weight_tier' => 'tier_c_large',
|
||||
'student_count'=> 890,
|
||||
'teacher_count'=> 42,
|
||||
'compliance' => 95.5,
|
||||
'status' => 'excellent',
|
||||
'annual_fee' => 900.00,
|
||||
],
|
||||
[
|
||||
'id' => 3,
|
||||
'code' => 'SCH-MC-03',
|
||||
'name' => 'مدرسة صرح الشهيد العسكرية',
|
||||
'governorate' => 'إربد',
|
||||
'weight_tier' => 'tier_b_medium',
|
||||
'student_count'=> 460,
|
||||
'teacher_count'=> 24,
|
||||
'compliance' => 92.0,
|
||||
'status' => 'good',
|
||||
'annual_fee' => 450.00,
|
||||
],
|
||||
[
|
||||
'id' => 4,
|
||||
'code' => 'SCH-MC-04',
|
||||
'name' => 'مدرسة البادية الشمالية الثانوية العسكرية',
|
||||
'governorate' => 'المفرق',
|
||||
'weight_tier' => 'tier_a_small',
|
||||
'student_count'=> 150,
|
||||
'teacher_count'=> 12,
|
||||
'compliance' => 88.0,
|
||||
'status' => 'warning_under_quota',
|
||||
'annual_fee' => 250.00,
|
||||
],
|
||||
[
|
||||
'id' => 5,
|
||||
'code' => 'SCH-MC-05',
|
||||
'name' => 'مدرسة القويرة الثانوية العسكرية',
|
||||
'governorate' => 'العقبة',
|
||||
'weight_tier' => 'tier_a_small',
|
||||
'student_count'=> 135,
|
||||
'teacher_count'=> 11,
|
||||
'compliance' => 91.0,
|
||||
'status' => 'good',
|
||||
'annual_fee' => 250.00,
|
||||
],
|
||||
[
|
||||
'id' => 6,
|
||||
'code' => 'SCH-MC-06',
|
||||
'name' => 'مدرسة الكرك الثانوية العسكرية',
|
||||
'governorate' => 'الكرك',
|
||||
'weight_tier' => 'tier_b_medium',
|
||||
'student_count'=> 420,
|
||||
'teacher_count'=> 22,
|
||||
'compliance' => 94.0,
|
||||
'status' => 'good',
|
||||
'annual_fee' => 450.00,
|
||||
],
|
||||
];
|
||||
|
||||
// Macro Alerts & Anomaly indicators
|
||||
$anomalies = [
|
||||
[
|
||||
'id' => 'ANM-01',
|
||||
'type' => 'speed_impossible',
|
||||
'school_name' => 'مدرسة البادية الشمالية العسكرية',
|
||||
'subject' => 'الرياضيات العلمي',
|
||||
'description' => 'حل 18 طالباً لمسألة تفاضل في 14 ثانية (تحت التدقيق الإشرافي)',
|
||||
'severity' => 'medium',
|
||||
'timestamp' => date('Y-m-d H:i', strtotime('-2 hours')),
|
||||
]
|
||||
];
|
||||
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'directorate' => $directorate,
|
||||
'schools' => $sampleSchools,
|
||||
'anomalies' => $anomalies,
|
||||
'radar' => [
|
||||
'top_teachers' => [
|
||||
['name' => 'أحمد المجالي', 'school' => 'الشهيد فيصل الثاني', 'subject' => 'الفيزياء', 'score' => 97.4, 'certified_badge' => true],
|
||||
['name' => 'خلدون بني هاني', 'school' => 'صرح الشهيد إربد', 'subject' => 'الرياضيات', 'score' => 96.8, 'certified_badge' => true],
|
||||
['name' => 'طارق الحنيطي', 'school' => 'الملك حسين الزرقاء', 'subject' => 'الكيمياء', 'score' => 95.9, 'certified_badge' => true],
|
||||
],
|
||||
'needing_support' => [
|
||||
['name' => 'معلم لغة عربية', 'school' => 'مدرسة القويرة', 'issue' => 'ضعف الأسئلة السقراطية وتجاوز زمن الشرح 35 دقيقة', 'score' => 74.2],
|
||||
]
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get School Principal / Field Supervisor Dashboard (لوحة مدير المدرسة والمشرف)
|
||||
* GET /api/supervisor/school-dashboard
|
||||
*/
|
||||
public function getSchoolDashboard(Request $request, Response $response): void
|
||||
{
|
||||
$schoolId = (int)($request->getQueryParams()['school_id'] ?? 1);
|
||||
|
||||
$school = [
|
||||
'id' => $schoolId,
|
||||
'name' => 'مدرسة الشهيد فيصل الثاني الثانوية العسكرية',
|
||||
'governorate' => 'العاصمة - عمان',
|
||||
'student_count' => 980,
|
||||
'teacher_count' => 48,
|
||||
'weight_tier' => 'tier_c_large',
|
||||
'director_name' => 'المقدم الركن / مدير المدرسة',
|
||||
'biweekly_cycle' => 'الدورة الرابعة (الفصل الأول)',
|
||||
'cycle_progress' => '42 من 48 حصة مسجلة (87.5%)',
|
||||
];
|
||||
|
||||
$teachers = [
|
||||
[
|
||||
'id' => 101,
|
||||
'name' => 'أحمد المجالي',
|
||||
'subject' => 'الفيزياء',
|
||||
'grade' => 'العاشر + الأول ثانوي',
|
||||
'quota_status' => 'completed', // 1 lesson every 2 weeks completed
|
||||
'last_lesson' => 'تطبيقات قوانين نيوتن الثالث والمصاعد',
|
||||
'ai_score' => 97.4,
|
||||
'recorded_at' => 'منذ 3 أيام',
|
||||
],
|
||||
[
|
||||
'id' => 102,
|
||||
'name' => 'عمر الحباشنة',
|
||||
'subject' => 'الرياضيات العلمي',
|
||||
'grade' => 'التوجيهي 2008',
|
||||
'quota_status' => 'pending', // Needs recording this week
|
||||
'last_lesson' => 'تطبيقات القيم القصوى والمعدلات المرتبطة',
|
||||
'ai_score' => 94.0,
|
||||
'recorded_at' => 'منذ 12 يوماً',
|
||||
],
|
||||
[
|
||||
'id' => 103,
|
||||
'name' => 'سليمان الطراونة',
|
||||
'subject' => 'الكيمياء',
|
||||
'grade' => 'الأول ثانوي',
|
||||
'quota_status' => 'completed',
|
||||
'last_lesson' => 'سرعة التفاعلات ونظرية التصادم',
|
||||
'ai_score' => 92.5,
|
||||
'recorded_at' => 'أمس',
|
||||
],
|
||||
[
|
||||
'id' => 104,
|
||||
'name' => 'محمد الغويري',
|
||||
'subject' => 'اللغة الإنجليزية',
|
||||
'grade' => 'العاشر',
|
||||
'quota_status' => 'completed',
|
||||
'last_lesson' => 'Conditional Sentences (Type 2 & 3)',
|
||||
'ai_score' => 96.0,
|
||||
'recorded_at' => 'منذ 5 أيام',
|
||||
],
|
||||
];
|
||||
|
||||
$activeExams = [
|
||||
[
|
||||
'id' => 'EX-MC-2026-01',
|
||||
'title' => 'الامتحان الموحد التجريبي - الرياضيات العلمي',
|
||||
'subject' => 'الرياضيات العلمي',
|
||||
'grade_level' => 'التوجيهي 2008',
|
||||
'scheduled_time' => 'اليوم - 09:00 صباحاً (موعد موحد لكافة المدارس)',
|
||||
'duration_minutes' => 60,
|
||||
'pushed_to_lab' => true,
|
||||
'status' => 'in_progress',
|
||||
'forms' => ['نموذج أ', 'نموذج ب'],
|
||||
'anti_cheating' => [
|
||||
'kiosk_mode_locked' => true,
|
||||
'dynamic_numbers' => true,
|
||||
'smart_sampling' => 'ثانيتان كل دقيقة (حجم الإجمالي 16 ميجابايت)',
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'school' => $school,
|
||||
'teachers' => $teachers,
|
||||
'active_exams' => $activeExams
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record Classroom Lesson via Supervisor App
|
||||
* POST /api/supervisor/record-lesson
|
||||
*/
|
||||
public function recordLesson(Request $request, Response $response): void
|
||||
{
|
||||
$body = $request->getBody();
|
||||
|
||||
$teacherName = $body['teacher_name'] ?? 'معلم معتمد';
|
||||
$subject = $body['subject'] ?? 'الفيزياء';
|
||||
$lessonTitle = $body['lesson_title'] ?? 'حصة صفية ميدانية';
|
||||
$fileSizeMb = (float)($body['file_size_mb'] ?? 340.0);
|
||||
$durationMin = (int)($body['duration_minutes'] ?? 45);
|
||||
|
||||
// Quality check constraint: Maximum 400 MB
|
||||
if ($fileSizeMb > 400.0) {
|
||||
$response->status(400)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'حجم الفيديو يتجاوز السقف المعتمد (400 ميجابايت). يرجى التحقق من ترميز الضغط 720p.'
|
||||
]);
|
||||
$directorateId = $this->resolveDirectorateId($request);
|
||||
if (!$directorateId) {
|
||||
$response->status(403)->json(['status' => 'error', 'message' => 'لا توجد مديرية ضمن نطاق صلاحيات الحساب']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Simulate AI pedagogical audit
|
||||
$aiScore = mt_rand(88, 98);
|
||||
$teacherTalkRatio = mt_rand(55, 68); // healthy balance between 50-70%
|
||||
|
||||
$socraticCheckpoints = [
|
||||
[
|
||||
'timestamp' => '14:20',
|
||||
'question' => 'لماذا تختلف قوة التجاذب الكتلي عند الاقتراب من سطح الأرض؟',
|
||||
'objective' => 'الربط بالمنهاج الوزاري - نتاجات الوحدة الثانية'
|
||||
],
|
||||
[
|
||||
'timestamp' => '28:45',
|
||||
'question' => 'ما هو التفسير الفيزيائي لقصور الجسم الذاتي في حركة المصعد؟',
|
||||
'objective' => 'إثارة التفكير الاستنتاجي السقراطي'
|
||||
]
|
||||
];
|
||||
$directorate = Database::selectOne(
|
||||
"SELECT d.id, d.code, d.name, d.type, d.commander_name, d.annual_contract_value,
|
||||
(SELECT COUNT(*) FROM schools s WHERE s.directorate_id = d.id AND s.is_active = 1) AS total_schools,
|
||||
(SELECT COUNT(*) FROM students st JOIN schools s ON s.id = st.school_id WHERE s.directorate_id = d.id) AS total_students,
|
||||
(SELECT COUNT(*) FROM teachers t JOIN schools s ON s.id = t.school_id WHERE s.directorate_id = d.id) AS total_teachers,
|
||||
(SELECT COUNT(*) FROM field_recorded_lessons f JOIN schools s ON s.id = f.school_id
|
||||
WHERE s.directorate_id = d.id AND f.recorded_at >= DATE_FORMAT(NOW(), '%Y-%m-01')) AS recorded_lessons_month,
|
||||
COALESCE((SELECT AVG(f.ai_alignment_score) FROM field_recorded_lessons f JOIN schools s ON s.id = f.school_id
|
||||
WHERE s.directorate_id = d.id AND f.ai_alignment_score IS NOT NULL), 0) AS ai_average_score,
|
||||
(SELECT COUNT(*) FROM unified_exams ue WHERE ue.directorate_id = d.id AND ue.status IN ('scheduled','in_progress')) AS active_unified_exams
|
||||
FROM directorates d WHERE d.id = ? AND d.is_active = 1 LIMIT 1",
|
||||
[$directorateId]
|
||||
);
|
||||
if (!$directorate) {
|
||||
$response->status(404)->json(['status' => 'error', 'message' => 'المديرية غير موجودة']);
|
||||
return;
|
||||
}
|
||||
|
||||
$lessonUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
||||
mt_rand(0, 0xffff),
|
||||
mt_rand(0, 0x0fff) | 0x4000,
|
||||
mt_rand(0, 0x3fff) | 0x8000,
|
||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
||||
$schools = Database::select(
|
||||
"SELECT s.id, s.code, s.name, s.governorate, s.weight_tier, s.annual_fee,
|
||||
(SELECT COUNT(*) FROM students st WHERE st.school_id = s.id) AS student_count,
|
||||
(SELECT COUNT(*) FROM teachers t WHERE t.school_id = s.id) AS teacher_count,
|
||||
(SELECT COUNT(DISTINCT f.teacher_id) FROM field_recorded_lessons f
|
||||
WHERE f.school_id = s.id AND f.recorded_at >= DATE_SUB(NOW(), INTERVAL 14 DAY)) AS teachers_recorded
|
||||
FROM schools s WHERE s.directorate_id = ? AND s.is_active = 1 ORDER BY s.name",
|
||||
[$directorateId]
|
||||
);
|
||||
$totalCompliance = 0.0;
|
||||
foreach ($schools as &$school) {
|
||||
$teacherCount = (int)$school['teacher_count'];
|
||||
$compliance = $teacherCount > 0 ? min(100, ((int)$school['teachers_recorded'] / $teacherCount) * 100) : 0.0;
|
||||
$school['compliance'] = round($compliance, 2);
|
||||
$school['status'] = $compliance >= 95 ? 'excellent' : ($compliance >= 80 ? 'good' : 'warning_under_quota');
|
||||
unset($school['teachers_recorded']);
|
||||
$totalCompliance += $compliance;
|
||||
}
|
||||
unset($school);
|
||||
$directorate['compliance_rate'] = count($schools) > 0 ? round($totalCompliance / count($schools), 2) : 0.0;
|
||||
|
||||
$anomalies = Database::select(
|
||||
"SELECT ess.id, 'exam_integrity' AS type, s.name AS school_name, ue.subject,
|
||||
CONCAT('شذوذ سرعة: ', ess.speed_anomalies_count, '، تجمع أخطاء: ', ess.error_clustering_count) AS description,
|
||||
'high' AS severity, ess.created_at AS timestamp
|
||||
FROM exam_school_sessions ess
|
||||
JOIN schools s ON s.id = ess.school_id
|
||||
JOIN unified_exams ue ON ue.id = ess.unified_exam_id
|
||||
WHERE s.directorate_id = ? AND ess.status = 'flagged_investigation'
|
||||
ORDER BY ess.id DESC LIMIT 50",
|
||||
[$directorateId]
|
||||
);
|
||||
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'message' => 'تم رفع الحصة بنجاح وإتمام التدقيق التربوي السيادي',
|
||||
'data' => [
|
||||
'lesson_uuid' => $lessonUuid,
|
||||
'teacher_name' => $teacherName,
|
||||
'subject' => $subject,
|
||||
'lesson_title' => $lessonTitle,
|
||||
'file_size_mb' => $fileSizeMb,
|
||||
'duration_minutes' => $durationMin,
|
||||
'ai_alignment_score' => $aiScore,
|
||||
'teacher_talk_ratio' => $teacherTalkRatio . '%',
|
||||
'status' => ($aiScore >= 85) ? 'approved_official' : 'under_supervisor_review',
|
||||
'socratic_checkpoints' => $socraticCheckpoints,
|
||||
'report_summary' => 'التزام تام بالخطة الفصلية، تسلسل منطقي في طرح المفاهيم، لغة تربوية سليمة خالية من المخالفات.'
|
||||
]
|
||||
'status' => 'success',
|
||||
'directorate' => $directorate,
|
||||
'schools' => $schools,
|
||||
'anomalies' => $anomalies,
|
||||
'radar' => ['top_teachers' => [], 'needing_support' => []],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push Unified Exam to School Computer Labs
|
||||
* POST /api/supervisor/exam/push-to-lab
|
||||
*/
|
||||
public function getSchoolDashboard(Request $request, Response $response): void
|
||||
{
|
||||
$requestedId = (int)$request->getQuery('school_id', 0);
|
||||
$schoolId = $this->resolveSchoolId($request, $requestedId);
|
||||
if (!$schoolId) {
|
||||
$response->status(403)->json(['status' => 'error', 'message' => 'المدرسة خارج نطاق صلاحيات الحساب']);
|
||||
return;
|
||||
}
|
||||
$school = Database::selectOne(
|
||||
"SELECT s.id, s.name, s.governorate, s.weight_tier, s.director_name,
|
||||
(SELECT COUNT(*) FROM students st WHERE st.school_id = s.id) AS student_count,
|
||||
(SELECT COUNT(*) FROM teachers t WHERE t.school_id = s.id) AS teacher_count
|
||||
FROM schools s WHERE s.id = ? AND s.is_active = 1 LIMIT 1",
|
||||
[$schoolId]
|
||||
);
|
||||
if (!$school) {
|
||||
$response->status(404)->json(['status' => 'error', 'message' => 'المدرسة غير موجودة']);
|
||||
return;
|
||||
}
|
||||
$teachers = Database::select(
|
||||
"SELECT t.id, t.full_name AS name, t.specialization AS subject,
|
||||
COALESCE(f.grade_level, '') AS grade,
|
||||
CASE WHEN f.recorded_at >= DATE_SUB(NOW(), INTERVAL 14 DAY) THEN 'completed' ELSE 'pending' END AS quota_status,
|
||||
COALESCE(f.lesson_title, '') AS last_lesson,
|
||||
COALESCE(f.ai_alignment_score, 0) AS ai_score,
|
||||
COALESCE(DATE_FORMAT(f.recorded_at, '%Y-%m-%d %H:%i'), '') AS recorded_at
|
||||
FROM teachers t
|
||||
LEFT JOIN field_recorded_lessons f ON f.id = (
|
||||
SELECT f2.id FROM field_recorded_lessons f2 WHERE f2.teacher_id = t.id ORDER BY f2.recorded_at DESC LIMIT 1
|
||||
)
|
||||
WHERE t.school_id = ? ORDER BY t.full_name",
|
||||
[$schoolId]
|
||||
);
|
||||
$activeExams = Database::select(
|
||||
"SELECT ue.id, ess.id AS session_id, ue.title, ue.subject, ue.grade_level, ue.scheduled_at AS scheduled_time,
|
||||
ue.duration_minutes, ess.pushed_to_lab, ess.status
|
||||
FROM exam_school_sessions ess JOIN unified_exams ue ON ue.id = ess.unified_exam_id
|
||||
WHERE ess.school_id = ? AND ue.status IN ('scheduled','in_progress') ORDER BY ue.scheduled_at",
|
||||
[$schoolId]
|
||||
);
|
||||
$recorded = count(array_filter($teachers, fn($t) => $t['quota_status'] === 'completed'));
|
||||
$school['biweekly_cycle'] = date('Y-m-d', strtotime('-14 days')) . ' — ' . date('Y-m-d');
|
||||
$school['cycle_progress'] = $recorded . ' من ' . count($teachers) . ' معلم';
|
||||
|
||||
$response->json(['status' => 'success', 'school' => $school, 'teachers' => $teachers, 'active_exams' => $activeExams, 'computer_labs' => []]);
|
||||
}
|
||||
|
||||
public function recordLesson(Request $request, Response $response): void
|
||||
{
|
||||
$body = $request->getBody();
|
||||
$schoolId = $this->resolveSchoolId($request, (int)($body['school_id'] ?? 0));
|
||||
$teacherId = (int)($body['teacher_id'] ?? 0);
|
||||
$subject = trim((string)($body['subject'] ?? ''));
|
||||
$grade = trim((string)($body['grade_level'] ?? ''));
|
||||
$title = trim((string)($body['lesson_title'] ?? ''));
|
||||
if (!$schoolId || !$teacherId || $subject === '' || $grade === '' || $title === '' || empty($_FILES['video'])) {
|
||||
$response->status(422)->json(['status' => 'error', 'message' => 'المدرسة والمعلم والمبحث والصف والعنوان وملف الفيديو مطلوبة']);
|
||||
return;
|
||||
}
|
||||
$teacher = Database::selectOne("SELECT id, full_name FROM teachers WHERE id = ? AND school_id = ? LIMIT 1", [$teacherId, $schoolId]);
|
||||
if (!$teacher) {
|
||||
$response->status(404)->json(['status' => 'error', 'message' => 'المعلم غير مرتبط بهذه المدرسة']);
|
||||
return;
|
||||
}
|
||||
$file = $_FILES['video'];
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || (int)$file['size'] > 400 * 1024 * 1024) {
|
||||
$response->status(422)->json(['status' => 'error', 'message' => 'ملف الفيديو غير صالح أو يتجاوز 400 ميجابايت']);
|
||||
return;
|
||||
}
|
||||
$uuid = $this->uuid();
|
||||
$extension = strtolower(pathinfo((string)$file['name'], PATHINFO_EXTENSION));
|
||||
if (!in_array($extension, ['mp4', 'mov', 'webm'], true)) {
|
||||
$response->status(422)->json(['status' => 'error', 'message' => 'صيغة الفيديو غير مدعومة']);
|
||||
return;
|
||||
}
|
||||
$videoUrl = VideoService::uploadToR2((string)$file['tmp_name'], "field-lessons/{$schoolId}/{$uuid}.{$extension}", (string)($file['type'] ?? 'video/mp4'));
|
||||
if (!$videoUrl) {
|
||||
$response->status(502)->json(['status' => 'error', 'message' => 'تعذر رفع الفيديو إلى Cloudflare R2']);
|
||||
return;
|
||||
}
|
||||
Database::insert(
|
||||
"INSERT INTO field_recorded_lessons
|
||||
(uuid, school_id, teacher_id, subject, grade_level, lesson_title, video_path, file_size_mb, duration_minutes, status, recorded_by_name, recorded_by_role)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending_ai', ?, ?)",
|
||||
[$uuid, $schoolId, $teacherId, $subject, $grade, $title, $videoUrl, round((int)$file['size'] / 1048576, 2), (int)($body['duration_minutes'] ?? 0), (string)($request->full_name ?? $request->uuid ?? 'staff'), $request->role === 'school_admin' ? 'principal' : 'supervisor']
|
||||
);
|
||||
$response->status(201)->json(['status' => 'success', 'message' => 'تم رفع الحصة وبانتظار التحليل الفعلي', 'data' => ['lesson_uuid' => $uuid, 'teacher_name' => $teacher['full_name'], 'subject' => $subject, 'lesson_title' => $title, 'file_size_mb' => round((int)$file['size'] / 1048576, 2), 'duration_minutes' => (int)($body['duration_minutes'] ?? 0), 'ai_alignment_score' => 0, 'teacher_talk_ratio' => '', 'status' => 'pending_ai', 'socratic_checkpoints' => [], 'report_summary' => 'بانتظار التحليل']]);
|
||||
}
|
||||
|
||||
public function pushExamToLab(Request $request, Response $response): void
|
||||
{
|
||||
$body = $request->getBody();
|
||||
$examId = $body['exam_id'] ?? 'EX-MC-2026-01';
|
||||
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'message' => 'تم بث الامتحان الموحد لمحطات مختبر الحاسوب وتفعيل البيئة المقفلة (Kiosk Mode)',
|
||||
'exam_id' => $examId,
|
||||
'pushed_at' => date('Y-m-d H:i:s'),
|
||||
'connected_nodes' => 32, // lab PCs
|
||||
'forms_ready' => ['Form A', 'Form B'],
|
||||
]);
|
||||
$examId = (int)($body['exam_id'] ?? 0);
|
||||
$schoolId = $this->resolveSchoolId($request, (int)($body['school_id'] ?? 0));
|
||||
if (!$examId || !$schoolId) {
|
||||
$response->status(422)->json(['status' => 'error', 'message' => 'معرف الامتحان والمدرسة مطلوبان']);
|
||||
return;
|
||||
}
|
||||
$exam = Database::selectOne("SELECT id FROM unified_exams WHERE id = ? LIMIT 1", [$examId]);
|
||||
if (!$exam) {
|
||||
$response->status(404)->json(['status' => 'error', 'message' => 'الامتحان غير موجود']);
|
||||
return;
|
||||
}
|
||||
Database::query("INSERT INTO exam_school_sessions (unified_exam_id, school_id, pushed_to_lab, pushed_to_lab_at) VALUES (?, ?, 1, NOW()) ON DUPLICATE KEY UPDATE pushed_to_lab = 1, pushed_to_lab_at = NOW()", [$examId, $schoolId]);
|
||||
$response->json(['status' => 'success', 'message' => 'تم تفعيل الامتحان للمختبر المسجل', 'exam_id' => $examId, 'school_id' => $schoolId, 'pushed_at' => date('Y-m-d H:i:s')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload Smart Sampled Panoramic Surveillance Video
|
||||
* POST /api/supervisor/exam/upload-panoramic-sample
|
||||
*/
|
||||
public function uploadPanoramicSample(Request $request, Response $response): void
|
||||
{
|
||||
$body = $request->getBody();
|
||||
$fileSizeMb = (float)($body['file_size_mb'] ?? 16.5);
|
||||
$durationSec = (int)($body['duration_seconds'] ?? 120);
|
||||
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'message' => 'تم حفظ العينة البانورامية المقتضبة بنجاح وربطها بسجل الامتحان',
|
||||
'file_size_mb' => $fileSizeMb,
|
||||
'duration_seconds' => $durationSec,
|
||||
'retention_policy' => 'حفظ محلي مشفر لمدة 14 يوماً حتى اعتماد النتائج',
|
||||
'correlated_anomalies'=> 0
|
||||
]);
|
||||
$sessionId = (int)($body['session_id'] ?? 0);
|
||||
$session = Database::selectOne("SELECT ess.id, ess.school_id FROM exam_school_sessions ess WHERE ess.id = ? LIMIT 1", [$sessionId]);
|
||||
if (!$session || !$this->resolveSchoolId($request, (int)$session['school_id']) || empty($_FILES['video'])) {
|
||||
$response->status(422)->json(['status' => 'error', 'message' => 'جلسة صحيحة وملف فيديو بانورامي مطلوبان']);
|
||||
return;
|
||||
}
|
||||
$file = $_FILES['video'];
|
||||
$url = VideoService::uploadToR2((string)$file['tmp_name'], "exam-samples/{$sessionId}/{$this->uuid()}.mp4", (string)($file['type'] ?? 'video/mp4'));
|
||||
if (!$url) {
|
||||
$response->status(502)->json(['status' => 'error', 'message' => 'تعذر رفع العينة إلى R2']);
|
||||
return;
|
||||
}
|
||||
$size = round((int)$file['size'] / 1048576, 2);
|
||||
Database::query("UPDATE exam_school_sessions SET panoramic_video_path = ?, panoramic_size_mb = ? WHERE id = ?", [$url, $size, $sessionId]);
|
||||
$response->json(['status' => 'success', 'message' => 'تم حفظ العينة وربطها بالجلسة', 'file_size_mb' => $size]);
|
||||
}
|
||||
|
||||
/**
|
||||
* توليد الامتحان الموحد بنموذجين متوازيين (نموذج أ ونموذج ب)
|
||||
* GET /api/unified-exams/dual-forms
|
||||
*/
|
||||
public function generateDualForms(Request $request, Response $response): void
|
||||
{
|
||||
$subject = (string)$request->getQuery('subject', 'الفيزياء');
|
||||
$gradeLevel = (string)$request->getQuery('grade_level', 'الأول ثانوي');
|
||||
|
||||
$forms = \App\Services\UnifiedExamService::generateDualForms($subject, $gradeLevel);
|
||||
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'data' => $forms
|
||||
]);
|
||||
$forms = \App\Services\UnifiedExamService::generateDualForms((string)$request->getQuery('subject', ''), (string)$request->getQuery('grade_level', ''));
|
||||
$response->json(['status' => 'success', 'data' => $forms]);
|
||||
}
|
||||
|
||||
/**
|
||||
* كشف الشذوذ الإحصائي ومكافحة الغش في جلسة الامتحان الموحد
|
||||
* POST /api/unified-exams/evaluate-integrity
|
||||
*/
|
||||
public function evaluateExamSessionIntegrity(Request $request, Response $response): void
|
||||
{
|
||||
$body = $request->getBody();
|
||||
$submissions = $body['submissions'] ?? [];
|
||||
|
||||
if (empty($submissions)) {
|
||||
// Default sample submissions demonstrating all 3 statistical anomalies
|
||||
$submissions = [
|
||||
[
|
||||
'student_id' => 101,
|
||||
'student_name' => 'سيف الدين خالد الرواشدة',
|
||||
'seat_number' => 'قاعة 1 — مقعد 04',
|
||||
'time_spent_seconds' => 195, // < 300s
|
||||
'score' => 95,
|
||||
'historical_average' => 50.0,
|
||||
'answers' => [
|
||||
1 => ['selected_option' => 0, 'is_correct' => true],
|
||||
2 => ['selected_option' => 0, 'is_correct' => true],
|
||||
]
|
||||
],
|
||||
[
|
||||
'student_id' => 102,
|
||||
'student_name' => 'عمر أحمد الحباشنة',
|
||||
'seat_number' => 'قاعة 1 — مقعد 05',
|
||||
'time_spent_seconds' => 720,
|
||||
'score' => 92,
|
||||
'historical_average' => 42.0, // Historical leap
|
||||
'answers' => [
|
||||
1 => ['selected_option' => 2, 'is_correct' => false], // identical error
|
||||
2 => ['selected_option' => 1, 'is_correct' => false],
|
||||
]
|
||||
],
|
||||
[
|
||||
'student_id' => 103,
|
||||
'student_name' => 'فيصل محمود الخريشا',
|
||||
'seat_number' => 'قاعة 1 — مقعد 06',
|
||||
'time_spent_seconds' => 740,
|
||||
'score' => 88,
|
||||
'historical_average' => 84.0,
|
||||
'answers' => [
|
||||
1 => ['selected_option' => 2, 'is_correct' => false], // identical error
|
||||
2 => ['selected_option' => 1, 'is_correct' => false],
|
||||
]
|
||||
]
|
||||
];
|
||||
$submissions = $request->getBody()['submissions'] ?? [];
|
||||
if (!is_array($submissions) || !$submissions) {
|
||||
$response->status(422)->json(['status' => 'error', 'message' => 'لا يمكن تقييم النزاهة دون تسليمات امتحان حقيقية']);
|
||||
return;
|
||||
}
|
||||
|
||||
$result = \App\Services\UnifiedExamService::evaluateExamSessionIntegrity($submissions);
|
||||
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'data' => $result
|
||||
]);
|
||||
$response->json(['status' => 'success', 'data' => \App\Services\UnifiedExamService::evaluateExamSessionIntegrity($submissions)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* إرسال ومضات التقارير الشهرية لأولياء الأمور عبر الواتساب وبوابة نبيه
|
||||
* POST /api/parent-reports/dispatch
|
||||
*/
|
||||
public function dispatchParentReports(Request $request, Response $response): void
|
||||
{
|
||||
$body = $request->getBody();
|
||||
$studentId = !empty($body['student_id']) ? (int)$body['student_id'] : null;
|
||||
$schoolId = !empty($body['school_id']) ? (int)$body['school_id'] : 1;
|
||||
|
||||
if ($studentId) {
|
||||
$res = \App\Services\ParentReportService::dispatchReportToGuardian($studentId);
|
||||
} else {
|
||||
$res = \App\Services\ParentReportService::dispatchBatchReports($schoolId);
|
||||
$schoolId = $this->resolveSchoolId($request, (int)($body['school_id'] ?? 0));
|
||||
if (!$schoolId) {
|
||||
$response->status(403)->json(['status' => 'error', 'message' => 'المدرسة خارج نطاق الصلاحية']);
|
||||
return;
|
||||
}
|
||||
|
||||
$response->json($res);
|
||||
$studentId = (int)($body['student_id'] ?? 0);
|
||||
if ($studentId) {
|
||||
$student = Database::selectOne("SELECT id FROM students WHERE id = ? AND school_id = ? LIMIT 1", [$studentId, $schoolId]);
|
||||
if (!$student) {
|
||||
$response->status(404)->json(['status' => 'error', 'message' => 'الطالب غير موجود في المدرسة']);
|
||||
return;
|
||||
}
|
||||
}
|
||||
$result = $studentId ? \App\Services\ParentReportService::dispatchReportToGuardian($studentId) : \App\Services\ParentReportService::dispatchBatchReports($schoolId);
|
||||
$response->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* استيراد كشف المدرسة وتشفير الأرقام الوطنية (AES-256-GCM)
|
||||
* POST /api/school-roster/import
|
||||
*/
|
||||
public function importSchoolRoster(Request $request, Response $response): void
|
||||
{
|
||||
$body = $request->getBody();
|
||||
$schoolId = !empty($body['school_id']) ? (int)$body['school_id'] : 1;
|
||||
$records = $body['records'] ?? [];
|
||||
|
||||
if (empty($records)) {
|
||||
$records = \App\Services\SchoolRosterService::getSampleRosterData();
|
||||
$schoolId = $this->resolveSchoolId($request, (int)($body['school_id'] ?? 0));
|
||||
$records = $body['records'] ?? [];
|
||||
if (!$schoolId || !is_array($records) || !$records) {
|
||||
$response->status(422)->json(['status' => 'error', 'message' => 'المدرسة وكشف حقيقي غير فارغ مطلوبان']);
|
||||
return;
|
||||
}
|
||||
$response->json(\App\Services\SchoolRosterService::importStudentRoster($schoolId, $records));
|
||||
}
|
||||
|
||||
$result = \App\Services\SchoolRosterService::importStudentRoster($schoolId, $records);
|
||||
private function resolveDirectorateId(Request $request): int
|
||||
{
|
||||
if ($request->role === 'super_admin') {
|
||||
$requested = (int)$request->getQuery('directorate_id', 0);
|
||||
if ($requested) return $requested;
|
||||
}
|
||||
if ($request->directorate_id) return (int)$request->directorate_id;
|
||||
if ($request->school_id) {
|
||||
$row = Database::selectOne("SELECT directorate_id FROM schools WHERE id = ? LIMIT 1", [$request->school_id]);
|
||||
return (int)($row['directorate_id'] ?? 0);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
$response->json($result);
|
||||
private function resolveSchoolId(Request $request, int $requested): int
|
||||
{
|
||||
if (in_array($request->role, ['school_admin', 'supervisor'], true)) {
|
||||
return $request->school_id && (!$requested || $requested === $request->school_id) ? (int)$request->school_id : 0;
|
||||
}
|
||||
if ($request->role === 'directorate_admin') {
|
||||
if (!$requested || !$request->directorate_id) return 0;
|
||||
$row = Database::selectOne("SELECT id FROM schools WHERE id = ? AND directorate_id = ? LIMIT 1", [$requested, $request->directorate_id]);
|
||||
return $row ? $requested : 0;
|
||||
}
|
||||
return $request->role === 'super_admin' ? $requested : 0;
|
||||
}
|
||||
|
||||
private function uuid(): string
|
||||
{
|
||||
$data = random_bytes(16);
|
||||
$data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
|
||||
$data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
|
||||
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user