resolveDirectorateId($request); if (!$directorateId) { $response->status(403)->json(['status' => 'error', 'message' => 'لا توجد مديرية ضمن نطاق صلاحيات الحساب']); return; } $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; } $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', 'directorate' => $directorate, 'schools' => $schools, 'anomalies' => $anomalies, 'radar' => ['top_teachers' => [], 'needing_support' => []], ]); } 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 = (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')]); } public function uploadPanoramicSample(Request $request, Response $response): void { $body = $request->getBody(); $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]); } public function generateDualForms(Request $request, Response $response): void { $forms = \App\Services\UnifiedExamService::generateDualForms((string)$request->getQuery('subject', ''), (string)$request->getQuery('grade_level', '')); $response->json(['status' => 'success', 'data' => $forms]); } public function evaluateExamSessionIntegrity(Request $request, Response $response): void { $submissions = $request->getBody()['submissions'] ?? []; if (!is_array($submissions) || !$submissions) { $response->status(422)->json(['status' => 'error', 'message' => 'لا يمكن تقييم النزاهة دون تسليمات امتحان حقيقية']); return; } $response->json(['status' => 'success', 'data' => \App\Services\UnifiedExamService::evaluateExamSessionIntegrity($submissions)]); } public function dispatchParentReports(Request $request, Response $response): void { $body = $request->getBody(); $schoolId = $this->resolveSchoolId($request, (int)($body['school_id'] ?? 0)); if (!$schoolId) { $response->status(403)->json(['status' => 'error', 'message' => 'المدرسة خارج نطاق الصلاحية']); return; } $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); } public function importSchoolRoster(Request $request, Response $response): void { $body = $request->getBody(); $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)); } 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; } 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)); } }