Files
saqel/backend/app/Controllers/DirectorateSupervisorController.php
T

290 lines
17 KiB
PHP

<?php
namespace App\Controllers;
use App\Core\Database;
use App\Core\Request;
use App\Core\Response;
use App\Services\VideoService;
class DirectorateSupervisorController
{
public function getDirectorateDashboard(Request $request, Response $response): void
{
$directorateId = $this->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));
}
}