db = \Config\Database::connect(); // Check if the database connection is established if (!$this->db->connect()) { log_message('error', 'Database connection failed.'); throw new \Exception('Database connection failed.'); } else { log_message('info', 'Database connection successful.'); } $this->userModel = new UserModel(); $this->configModel = new ConfigurationModel(); $this->studentClassModel = new StudentClassModel(); $this->studentModel = new StudentModel(); $this->allergyModel = new StudentAllergyModel(); $this->conditionModel = new StudentMedicalConditionModel(); $this->classSectionModel = new ClassSectionModel(); $this->emergencyContact = new EmergencyContactModel(); $this->enrollmentModel = new EnrollmentModel(); $this->semester = getSemester(); $this->schoolYear = $this->configModel->getConfig('school_year'); helper(['url', 'form']); } public function assignClassStudent() { $isAjax = $this->request->isAJAX(); $studentId = (int) $this->request->getPost('student_id'); $rawSections = $this->request->getPost('class_section_id'); $isEventOnly = $this->request->getPost('is_event_only') ? 1 : 0; $classSectionIds = []; // Normalize any input into an array of unique positive IDs if (is_array($rawSections)) { $classSectionIds = array_map('intval', $rawSections); } elseif ($rawSections !== null && $rawSections !== '') { $parts = is_string($rawSections) ? preg_split('/[,\s]+/', $rawSections) : [$rawSections]; $classSectionIds = array_map('intval', $parts); } $classSectionIds = array_values(array_unique(array_filter($classSectionIds, static fn($v) => $v > 0))); $userId = (int) (session()->get('user_id') ?? 0); $now = utc_now(); $jsonOut = function (array $payload, int $code = 200) { // Always return a fresh CSRF for next request (works with csrfRegenerate = true) $payload['csrfTokenName'] = csrf_token(); $payload['csrfHash'] = csrf_hash(); $payload[csrf_token()] = csrf_hash(); return $this->response->setStatusCode($code)->setJSON($payload); }; // Validate input if (!$studentId || empty($classSectionIds)) { $msg = 'Missing required data (student/class section).'; return $isAjax ? $jsonOut(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg); } // Check student $student = $this->studentModel->find($studentId); if (!$student) { $msg = 'Student not found.'; return $isAjax ? $jsonOut(['ok' => false, 'message' => $msg], 404) : redirect()->back()->with('error', $msg); } // Robust section lookup: allow id OR class_section_id $sections = $this->classSectionModel ->groupStart() ->whereIn('class_section_id', $classSectionIds) ->orWhereIn('id', $classSectionIds) ->groupEnd() ->findAll(); if (empty($sections)) { $msg = 'Class/Section not found.'; return $isAjax ? $jsonOut(['ok' => false, 'message' => $msg], 404) : redirect()->back()->with('error', $msg); } // Map for quick lookup; preserve requested order $sectionMap = []; foreach ($sections as $sec) { $sectionMap[(int)($sec['class_section_id'] ?? 0)] = $sec; } $existing = $this->studentClassModel ->where('student_id', $studentId) ->where('school_year', (string)$this->schoolYear) ->findAll(); $existingBySection = []; foreach ($existing as $row) { $existingBySection[(int)($row['class_section_id'] ?? 0)] = $row; } $displayNames = []; foreach ($classSectionIds as $cid) { if (!isset($sectionMap[$cid])) continue; $eventFlag = $isEventOnly ? 1 : 0; if (isset($existingBySection[$cid])) { $eventFlag = (int)($existingBySection[$cid]['is_event_only'] ?? 0); } $name = $this->formatClassSectionDisplayName($sectionMap[$cid], $cid); if ($eventFlag) { $name .= ' (Event)'; } $displayNames[] = $name; } if (empty($displayNames)) { $msg = 'Class/Section not found.'; return $isAjax ? $jsonOut(['ok' => false, 'message' => $msg], 404) : redirect()->back()->with('error', $msg); } if (count($displayNames) !== count($classSectionIds)) { $msg = 'One or more selected classes do not exist.'; return $isAjax ? $jsonOut(['ok' => false, 'message' => $msg], 404) : redirect()->back()->with('error', $msg); } $primarySectionId = $classSectionIds[0]; $primarySection = $sectionMap[$primarySectionId] ?? reset($sectionMap); // 🔎 Derive parent class_id from the section row (fallback to raw query if needed) $parentClassId = (int)($primarySection['class_id'] ?? 0); if (!$parentClassId) { // Adjust table name if your schema uses snake_case like 'class_section' $row = $this->db->table('classSection') ->select('class_id') ->groupStart() ->where('id', $primarySection['id'] ?? $primarySectionId) ->orWhere('class_section_id', $primarySectionId) ->groupEnd() ->get()->getRowArray(); if ($row && isset($row['class_id'])) { $parentClassId = (int)$row['class_id']; } } $this->db->transBegin(); try { // Upsert student_class entries per selection $scPk = $this->studentClassModel->primaryKey ?? 'id'; foreach ($classSectionIds as $cid) { $eventFlag = $isEventOnly ? 1 : 0; if (isset($existingBySection[$cid])) { $eventFlag = (int)($existingBySection[$cid]['is_event_only'] ?? 0); } $payload = [ 'student_id' => $studentId, 'class_section_id' => $cid, 'school_year' => (string)$this->schoolYear, 'is_event_only' => $eventFlag, 'updated_by' => $userId ?: null, 'updated_at' => $now, ]; if (isset($existingBySection[$cid])) { if (!$this->studentClassModel->update($existingBySection[$cid][$scPk], $payload)) { throw new \RuntimeException('Failed to update assignment: ' . json_encode($this->studentClassModel->errors())); } } else { $payload['created_at'] = $now; if (!$this->studentClassModel->insert($payload)) { throw new \RuntimeException('Failed to insert assignment: ' . json_encode($this->studentClassModel->errors())); } } } $attnStats = []; $scoreStats = []; if (!$isEventOnly) { // Update enrollment for current term (if exists) $enroll = $this->enrollmentModel ->where('student_id', $studentId) ->where('school_year', (string)$this->schoolYear) ->where('semester', (string)$this->semester) ->first(); if ($enroll) { $enPk = $this->enrollmentModel->primaryKey ?? 'id'; $result = \Config\Services::enrollmentStatus(false)->upsertStatus([ 'id' => (int) $enroll[$enPk], 'student_id' => $studentId, 'parent_id' => (int) ($enroll['parent_id'] ?? 0), 'school_year' => (string) $this->schoolYear, 'semester' => (string) $this->semester, 'class_section_id' => $primarySectionId, 'enrollment_status' => 'payment pending', // Ensure admission is marked accepted once moved out of review 'admission_status' => 'accepted', 'updated_at' => $now, ], $userId ?: null, 'student_class_assignment'); if ((int) ($result['id'] ?? 0) <= 0) { throw new \RuntimeException('Failed to update enrollment.'); } } // 🔄 Re-tag attendance rows for this student in the current term (section + class) $attnStats = $this->updateStudentAttendanceSection( $studentId, $primarySectionId, $parentClassId ?: 0, // safe default if missing (string)$this->semester, (string)$this->schoolYear, $userId ?: null ); // 🔄 Re-tag score rows (quiz, project, homework, participation, exams, aggregates) for current term $scoreStats = $this->updateStudentScoresSection( $studentId, $primarySectionId, (string)$this->semester, (string)$this->schoolYear, $userId ?: null ); } if ($this->db->transStatus() === false) { throw new \RuntimeException('Transaction failed.'); } $this->db->transCommit(); $resp = [ 'ok' => true, 'student_id' => $studentId, 'class_section_id' => $primarySectionId, 'class_section_ids' => $classSectionIds, 'class_section_name' => implode(', ', $displayNames), 'class_section_names'=> $displayNames, 'attendance_updates' => $attnStats, // {attendance_data_updated, attendance_record_updated} 'score_updates' => $scoreStats, // per-table updated counts 'message' => 'Assignment saved.', ]; return $isAjax ? $jsonOut($resp, 200) : redirect()->to(base_url('administrator/student_class_assignment'))->with('success', $resp['message']); } catch (\Throwable $e) { $this->db->transRollback(); $msg = 'Unable to assign class: ' . $e->getMessage(); return $isAjax ? $jsonOut(['ok' => false, 'message' => $msg], 500) : redirect()->back()->with('error', $msg); } } /** * Remove a student-class assignment for the current term. */ public function removeClassStudent() { $isAjax = $this->request->isAJAX(); $studentId = (int) $this->request->getPost('student_id'); $classSectionId = (int) $this->request->getPost('class_section_id'); $userId = (int) (session()->get('user_id') ?? 0); $now = utc_now(); $jsonOut = function (array $payload, int $code = 200) { $payload['csrfTokenName'] = csrf_token(); $payload['csrfHash'] = csrf_hash(); $payload[csrf_token()] = csrf_hash(); return $this->response->setStatusCode($code)->setJSON($payload); }; if (!$studentId || !$classSectionId) { $msg = 'Missing required data (student/class section).'; return $isAjax ? $jsonOut(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg); } // Verify assignment exists for this term $row = $this->studentClassModel ->where('student_id', $studentId) ->where('class_section_id', $classSectionId) ->where('school_year', (string)$this->schoolYear) ->first(); if (!$row) { $msg = 'Assignment not found for this student/class.'; return $isAjax ? $jsonOut(['ok' => false, 'message' => $msg], 404) : redirect()->back()->with('error', $msg); } // Remaining assignments BEFORE delete (for enrollment swap) $beforeIds = $this->studentClassModel->getClassSectionIdsByStudentId($studentId, (string)$this->schoolYear); $this->db->transBegin(); try { // Delete the assignment if (!$this->studentClassModel ->where('student_id', $studentId) ->where('class_section_id', $classSectionId) ->where('school_year', (string)$this->schoolYear) ->delete()) { throw new \RuntimeException('Failed to remove assignment.'); } // Determine remaining assignments AFTER delete $remainingIds = $this->studentClassModel->getClassSectionIdsByStudentId($studentId, (string)$this->schoolYear); $remainingNames = $this->studentClassModel->getClassSectionsByStudentId($studentId, (string)$this->schoolYear, true); $remainingDisplay = !empty($remainingNames) ? implode(', ', $remainingNames) : ''; // If enrollment pointed to removed class, move to another remaining class or null $enroll = $this->enrollmentModel ->where('student_id', $studentId) ->where('school_year', (string)$this->schoolYear) ->where('semester', (string)$this->semester) ->first(); if ($enroll) { $enPk = $this->enrollmentModel->primaryKey ?? 'id'; $newEnrollmentClass = null; if (!empty($remainingIds)) { $newEnrollmentClass = $remainingIds[0]; } if ((int)($enroll['class_section_id'] ?? 0) === $classSectionId || $newEnrollmentClass !== null) { $this->enrollmentModel->update($enroll[$enPk], [ 'class_section_id' => $newEnrollmentClass, 'updated_at' => $now, 'updated_by' => $userId ?: null, ]); } } if ($this->db->transStatus() === false) { throw new \RuntimeException('Transaction failed.'); } $this->db->transCommit(); $resp = [ 'ok' => true, 'student_id' => $studentId, 'removed_class_id' => $classSectionId, 'remaining_ids' => $remainingIds, 'remaining_names' => $remainingNames, 'remaining_display' => $remainingDisplay !== '' ? $remainingDisplay : 'No class assigned', 'message' => 'Class removed.', ]; return $isAjax ? $jsonOut($resp, 200) : redirect()->to(base_url('administrator/student_class_assignment'))->with('success', $resp['message']); } catch (\Throwable $e) { $this->db->transRollback(); $msg = 'Unable to remove class: ' . $e->getMessage(); return $isAjax ? $jsonOut(['ok' => false, 'message' => $msg], 500) : redirect()->back()->with('error', $msg); } } private function updateStudentAttendanceSection( int $studentId, int $newClassSectionId, int $newClassId, string $semester, string $schoolYear, ?int $modifiedBy = null ): array { $now = utc_now(); // ---- attendance_data: set class_section_id + class_id ---- $builderData = $this->db->table('attendance_data'); $builderData ->where('student_id', $studentId) ->where('semester', $semester) ->where('school_year', $schoolYear) ->set([ 'class_section_id' => $newClassSectionId, 'class_id' => $newClassId, 'updated_at' => $now, ]); if ($modifiedBy) { $builderData->set('modified_by', $modifiedBy); } if ($builderData->update() === false) { $err = $this->db->error(); throw new \RuntimeException('Failed to update attendance_data: ' . ($err['message'] ?? 'unknown DB error')); } $dataUpdated = $this->db->affectedRows(); // ---- attendance_record: set class_section_id (table has no class_id) ---- $builderRec = $this->db->table('attendance_record'); $builderRec ->where('student_id', $studentId) ->where('semester', $semester) ->where('school_year', $schoolYear) ->set([ 'class_section_id' => $newClassSectionId, 'updated_at' => $now, ]); if ($modifiedBy) { $builderRec->set('modified_by', $modifiedBy); } if ($builderRec->update() === false) { $err = $this->db->error(); throw new \RuntimeException('Failed to update attendance_record: ' . ($err['message'] ?? 'unknown DB error')); } $recUpdated = $this->db->affectedRows(); return [ 'attendance_data_updated' => $dataUpdated, 'attendance_record_updated' => $recUpdated, ]; } /** * Update all score-related tables for a student to the new class_section_id * within the provided semester and school year. * Affected tables: homework, quiz, project, participation, midterm_exam, * final_exam, final_score, semester_scores. */ private function updateStudentScoresSection( int $studentId, int $newClassSectionId, string $semester, string $schoolYear, ?int $modifiedBy = null ): array { $now = utc_now(); $tables = [ 'homework', 'quiz', 'project', 'participation', 'midterm_exam', 'final_exam', 'final_score', 'semester_scores', ]; $results = []; foreach ($tables as $tbl) { $builder = $this->db->table($tbl); $builder ->where('student_id', $studentId) ->where('semester', $semester) ->where('school_year', $schoolYear) ->set([ 'class_section_id' => $newClassSectionId, 'updated_at' => $now, ]); // Only set updated_by if the column exists in table schema. For most it does. if ($modifiedBy !== null) { $builder->set('updated_by', $modifiedBy); } if ($builder->update() === false) { $err = $this->db->error(); throw new \RuntimeException('Failed to update ' . $tbl . ': ' . ($err['message'] ?? 'unknown DB error')); } $results[$tbl . '_updated'] = $this->db->affectedRows(); } return $results; } /** * Build a display name from whatever fields the section row has. * Supports: class_section_name, section_name, name, title, grade/letter combos. */ private function formatClassSectionDisplayName(array $row, int $fallbackId): string { if (!empty($row['class_section_name'])) return (string) $row['class_section_name']; if (!empty($row['section_name'])) return (string) $row['section_name']; if (!empty($row['name'])) return (string) $row['name']; if (!empty($row['title'])) return (string) $row['title']; $grade = $row['grade_name'] ?? $row['grade'] ?? $row['class_name'] ?? null; $letter = $row['section'] ?? $row['section_letter'] ?? $row['letter'] ?? null; if ($grade && $letter) return "{$grade} - {$letter}"; if ($grade) return (string) $grade; return 'Section #' . $fallbackId; } public function studentClassAssignment() { // Resolve selected year and available years $selectedYear = trim((string)($this->request->getGet('schoolYear') ?? '')); if ($selectedYear === '') $selectedYear = (string)($this->schoolYear ?? ''); $yearsRows = $this->db->table('enrollments') ->select('DISTINCT school_year', false) ->orderBy('school_year', 'DESC') ->get()->getResultArray(); $schoolYears = array_values(array_filter(array_map(static function ($r) { return isset($r['school_year']) ? (string)$r['school_year'] : null; }, $yearsRows))); // Retrieve students with an enrollment in the selected year (any status) $students = $this->studentModel ->select('students.id, students.firstname, students.lastname, students.registration_date, students.age, students.parent_id, students.registration_grade') ->join('enrollments e', 'e.student_id = students.id', 'inner') ->where('e.school_year', $selectedYear) ->groupBy('students.id') ->orderBy('students.lastname', 'ASC') ->findAll(); // Fallback: if none found (data inconsistency), include students even without an enrollment row if (empty($students)) { $students = $this->studentModel ->select('students.id, students.firstname, students.lastname, students.registration_date, students.age, students.parent_id, students.registration_grade') ->orderBy('students.lastname', 'ASC') ->findAll(); } service('studentYearStatus')->attachToStudents($students, $selectedYear); $studentData = []; foreach ($students as $student) { $sectionNames = $this->studentClassModel->getClassSectionsByStudentIdWithFlags((int)$student['id'], $selectedYear, true); $sectionIds = $this->studentClassModel->getClassSectionIdsByStudentId((int)$student['id'], $selectedYear); $sectionDisplay = !empty($sectionNames) ? implode(', ', $sectionNames) : ''; // Use primary parent or fallback to second $pid = (int)($student['parent_id'] ?? 0); if ($pid <= 0) $pid = (int)($student['secondparent_user_id'] ?? 0); $emergencyInfo = $pid > 0 ? ($this->emergencyContact->getEmergencyContactByParentId($pid) ?? []) : []; $emergencyContactName = $emergencyInfo['emergency_contact_name'] ?? ''; $emergencyContactPhone = $emergencyInfo['cellphone'] ?? ''; $studentData[] = [ 'student_id' => (int)$student['id'], 'name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')), 'age' => $student['age'] ?? 'N/A', 'email' => $emergencyContactName, 'phone' => $emergencyContactPhone, 'registration_grade' => $student['registration_grade'] ?? 'N/A', 'class_section_name' => $sectionDisplay !== '' ? $sectionDisplay : 'No class assigned', 'class_section_names' => $sectionNames, 'class_section_ids' => $sectionIds, 'new_student' => ((int)($student['is_new'] ?? 0) === 1) ? 'Yes' : 'No', 'registration_date' => $student['registration_date'] ?? '', 'school_year' => $selectedYear, 'semester' => (string)($this->semester ?? ''), ]; } // Classes for selected year (fallback to all) $classes = $this->classSectionModel ->select('id, class_section_id, class_section_name, school_year') ->where('school_year', $selectedYear) ->orderBy('class_section_name','ASC') ->findAll(); if (empty($classes)) $classes = $this->classSectionModel->select('id, class_section_id, class_section_name')->orderBy('class_section_name','ASC')->findAll(); // Prepare data for view return view('administrator/student_class_assignment', [ 'students' => $studentData, 'classes' => $classes, 'schoolYears' => $schoolYears, 'selectedYear' => $selectedYear, 'currentYear' => (string)($this->schoolYear ?? ''), 'isCurrentYear'=> ((string)$selectedYear === (string)($this->schoolYear ?? '')), ]); } /** * GET admin view: page to trigger auto-distribution for a class/year. */ public function autoDistributePage() { // Resolve selected year and available years $selectedYear = trim((string)($this->request->getGet('schoolYear') ?? '')); if ($selectedYear === '') $selectedYear = (string)($this->schoolYear ?? ''); $yearsRows = $this->db->table('enrollments') ->select('DISTINCT school_year', false) ->orderBy('school_year', 'DESC') ->get()->getResultArray(); $schoolYears = array_values(array_filter(array_map(static function ($r) { return isset($r['school_year']) ? (string)$r['school_year'] : null; }, $yearsRows))); // Classes for selected year (fallback to all) $classes = $this->classSectionModel ->select('id, class_id, class_section_id, class_section_name, school_year') ->where('school_year', $selectedYear) ->orderBy('class_section_name','ASC') ->findAll(); if (empty($classes)) $classes = $this->classSectionModel->select('id, class_id, class_section_id, class_section_name')->orderBy('class_section_name','ASC')->findAll(); return view('administrator/sections_auto_distribute', [ 'classes' => $classes, 'schoolYears' => $schoolYears, 'selectedYear' => $selectedYear, 'currentYear' => (string)($this->schoolYear ?? ''), ]); } /** * POST admin endpoint: create draft balanced distribution rows for a class. * * Input: class_id/class_section_id, section_count, min_students_per_section, * max_students_per_section, school_year. */ public function autoDistributeSections() { $isAjax = $this->request->isAJAX(); $json = function (array $p, int $code = 200) { $p['csrfTokenName'] = csrf_token(); $p['csrfHash'] = csrf_hash(); $p[csrf_token()] = csrf_hash(); return $this->response->setStatusCode($code)->setJSON($p); }; try { $classId = (int) $this->request->getPost('class_id'); $classSectionId = (int) $this->request->getPost('class_section_id'); $sectionCount = (int) $this->request->getPost('section_count'); $minPerSection = (int) $this->request->getPost('min_students_per_section'); $maxRaw = trim((string) ($this->request->getPost('max_students_per_section') ?? '')); $maxPerSection = $maxRaw === '' ? null : (int) $maxRaw; $year = trim((string) ($this->request->getPost('school_year') ?? $this->schoolYear)); $this->ensureClassSectionsForYear($year); if ($classId <= 0 && $classSectionId > 0) { $cid = $this->classSectionModel->getClassId($classSectionId); $classId = (int) ($cid ?? 0); } if ($classId <= 0 || $sectionCount <= 0 || $minPerSection <= 0 || ($maxPerSection !== null && $maxPerSection <= 0)) { $msg = 'Enter a valid class, section count, minimum size, and optional maximum size.'; return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg); } $cands = $this->distributionCandidates($classId, $year); if (empty($cands)) { $msg = 'No promoted students found to distribute for selected class/year.'; return $isAjax ? $json(['ok' => false, 'message' => $msg], 200) : redirect()->back()->with('info', $msg); } $total = count($cands); $baseSection = $this->sectionForDistribution($classSectionId, $year); // Fetch lettered sections for this class. The requested section count is a max: // if the class cannot split into 2+ sections, keep the assignment on the base grade. $letters = $this->letterSectionsForDistribution($classId, $year); $availableSectionCount = count($letters); if (!$baseSection || (int)($baseSection['class_id'] ?? 0) !== $classId) { $msg = 'No base grade found for the selected class.'; return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg); } $actualSectionCount = min($sectionCount, $availableSectionCount); if ($minPerSection > 0) { $actualSectionCount = min($actualSectionCount, max(1, intdiv($total, $minPerSection))); } if ($maxPerSection !== null) { $minimumNeededForCapacity = (int)ceil($total / $maxPerSection); $capacitySectionCount = max(1, $availableSectionCount); if ($minimumNeededForCapacity > $capacitySectionCount || $minimumNeededForCapacity > $sectionCount) { $msg = 'Capacity exceeded: available sections can hold at most ' . ($capacitySectionCount * $maxPerSection) . ' students, but ' . $total . ' must be assigned.'; return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg); } $actualSectionCount = max($actualSectionCount, $minimumNeededForCapacity); } if ($actualSectionCount < 2) { $letters = [$baseSection]; } else { $letters = array_slice($letters, 0, $actualSectionCount); } $buckets = $this->buildBalancedDistribution($cands, $letters, $minPerSection, $maxPerSection); $draftModel = new StudentSectionDistributionDraftModel(); $promo = new \App\Models\PromotionQueueModel(); $updatedBy = (int)(session()->get('user_id') ?? 0) ?: null; $now = utc_now(); $batchKey = sha1($year . ':' . $classId . ':' . microtime(true)); $draftIdByStudentId = []; $this->db->transStart(); foreach ($buckets as $b) { $secId = (int)$b['class_section_id']; foreach ($b['assigned'] as $student) { $sid = (int)$student['student_id']; $draftId = $this->upsertDistributionDraft($draftModel, [ 'student_id' => $sid, 'class_id' => $classId, 'class_section_id' => $secId, 'school_year' => $year, 'previous_school_year' => (string)($student['school_year_from'] ?? ''), 'previous_final_score' => $student['previous_final_score'], 'score_group' => $student['score_group'], 'status' => 'pending', 'batch_key' => $batchKey, 'created_by' => $updatedBy, 'updated_at' => $now, ]); if ($draftId > 0) { $draftIdByStudentId[$sid] = $draftId; } if ((int)($student['promotion_queue_id'] ?? 0) > 0) { $promo->update((int)$student['promotion_queue_id'], [ 'to_class_id' => $classId, 'to_class_section_id' => $secId, 'status' => 'assigned', 'updated_by' => $updatedBy, 'updated_at' => $now, ]); } } } $this->db->transComplete(); if (!$this->db->transStatus()) { $msg = 'Distribution could not be saved. No official student class rows were changed.'; return $isAjax ? $json(['ok' => false, 'message' => $msg], 500) : redirect()->back()->with('error', $msg); } $nameById = []; foreach ($letters as $secRow) { $nameById[(int)$secRow['class_section_id']] = (string)($secRow['class_section_name'] ?? ''); } $summary = []; foreach ($buckets as $b) { $secId = (int)$b['class_section_id']; $scores = array_map(static fn($s): float => (float)$s['previous_final_score'], $b['assigned']); $groups = ['90_100' => 0, '80_89' => 0, '70_79' => 0, '69_below' => 0]; $male = 0; $female = 0; $studentNames = []; $studentAssignments = []; foreach ($b['assigned'] as $student) { $groups[$student['score_group']] = ($groups[$student['score_group']] ?? 0) + 1; $gender = strtolower((string)($student['gender'] ?? '')); if ($gender === 'female') $female++; else $male++; $studentId = (int)($student['student_id'] ?? 0); $studentName = trim((string)($student['student_name'] ?? '')); if ($studentName === '') { $studentName = 'Student #' . $studentId; } $studentNames[] = $studentName; $studentAssignments[] = [ 'draft_id' => $draftIdByStudentId[$studentId] ?? 0, 'student_id' => $studentId, 'student_name' => $studentName, 'age_at_reference' => $student['age_at_reference'] ?? null, 'gender' => (string)($student['gender'] ?? ''), 'last_year_class_section' => (string)($student['last_year_class_section'] ?? ''), 'previous_final_score' => $student['previous_final_score'] ?? null, 'class_id' => $classId, 'class_section_id' => $secId, 'class_section_name' => $nameById[$secId] ?? (string)$secId, ]; } usort($studentNames, static fn($a, $b): int => strnatcasecmp((string)$a, (string)$b)); usort($studentAssignments, static function (array $a, array $b): int { return strnatcasecmp((string)($a['student_name'] ?? ''), (string)($b['student_name'] ?? '')); }); $summary[] = [ 'class_id' => $classId, 'class_section_id' => $secId, 'class_section_name' => $nameById[$secId] ?? (string)$secId, 'total' => count($b['assigned']), 'male' => $male, 'female' => $female, 'score_groups' => $groups, 'average_score' => count($scores) > 0 ? round(array_sum($scores) / count($scores), 2) : null, 'student_names' => $studentNames, 'student_assignments'=> $studentAssignments, ]; } return $isAjax ? $json(['ok' => true, 'message' => 'Draft distribution saved. Students will move to student_class when they enroll.', 'sections' => $summary]) : redirect()->back()->with('success', 'Draft distribution saved.'); } catch (\Throwable $e) { $msg = 'Auto distribution failed: ' . $e->getMessage(); return $isAjax ? $json(['ok' => false, 'message' => $msg], 500) : redirect()->back()->with('error', $msg); } } public function updateDistributionDraft() { $json = function (array $p, int $code = 200) { $p['csrfTokenName'] = csrf_token(); $p['csrfHash'] = csrf_hash(); $p[csrf_token()] = csrf_hash(); return $this->response->setStatusCode($code)->setJSON($p); }; try { $draftId = (int)$this->request->getPost('draft_id'); $targetSectionId = (int)$this->request->getPost('class_section_id'); if ($draftId <= 0 || $targetSectionId <= 0) { return $json(['ok' => false, 'message' => 'Select a valid student draft and target section.'], 400); } $draftModel = new StudentSectionDistributionDraftModel(); $draft = $draftModel->where('id', $draftId) ->where('status', 'pending') ->first(); if (!$draft) { return $json(['ok' => false, 'message' => 'This draft assignment is no longer editable.'], 404); } $year = (string)($draft['school_year'] ?? ''); $targetSection = $this->sectionForDistribution($targetSectionId, $year); if (!$targetSection) { return $json(['ok' => false, 'message' => 'Target class or section must be valid for the selected school year.'], 400); } $targetClassId = (int)($targetSection['class_id'] ?? 0); $updatedBy = (int)(session()->get('user_id') ?? 0) ?: null; $now = utc_now(); $this->db->transStart(); $draftModel->update($draftId, [ 'class_id' => $targetClassId, 'class_section_id' => $targetSectionId, 'updated_at' => $now, ]); if ($this->db->tableExists('promotion_queue')) { $this->db->table('promotion_queue') ->where('student_id', (int)($draft['student_id'] ?? 0)) ->where('school_year_to', $year) ->whereIn('status', ['queued', 'assigned']) ->update([ 'to_class_id' => $targetClassId, 'to_class_section_id' => $targetSectionId, 'status' => 'assigned', 'updated_by' => $updatedBy, 'updated_at' => $now, ]); } $this->db->transComplete(); if (!$this->db->transStatus()) { return $json(['ok' => false, 'message' => 'Draft assignment could not be updated.'], 500); } return $json(['ok' => true, 'message' => 'Draft assignment updated.']); } catch (\Throwable $e) { return $json(['ok' => false, 'message' => 'Draft assignment update failed: ' . $e->getMessage()], 500); } } public function updateDistributionCandidate() { $json = function (array $p, int $code = 200) { $p['csrfTokenName'] = csrf_token(); $p['csrfHash'] = csrf_hash(); $p[csrf_token()] = csrf_hash(); return $this->response->setStatusCode($code)->setJSON($p); }; try { $studentId = (int)$this->request->getPost('student_id'); $targetSectionId = (int)$this->request->getPost('class_section_id'); $year = trim((string)($this->request->getPost('school_year') ?? $this->schoolYear)); if ($studentId <= 0 || $targetSectionId <= 0 || $year === '') { return $json(['ok' => false, 'message' => 'Select a valid student, year, and target assignment.'], 400); } $targetSection = $this->sectionForDistribution($targetSectionId, $year); if (!$targetSection) { return $json(['ok' => false, 'message' => 'Target class or section must be valid for the selected school year.'], 400); } $targetClassId = (int)($targetSection['class_id'] ?? 0); if ($targetClassId <= 0) { return $json(['ok' => false, 'message' => 'Target class could not be resolved.'], 400); } $queueRow = null; if ($this->db->tableExists('promotion_queue')) { $queueRow = $this->db->table('promotion_queue') ->where('student_id', $studentId) ->where('school_year_to', $year) ->whereIn('status', ['queued', 'assigned']) ->orderBy('id', 'DESC') ->get() ->getRowArray(); } $previousSchoolYear = (string)($queueRow['school_year_from'] ?? ($this->previousSchoolYearName($year) ?? '')); $previousScore = $this->previousAverageScore($studentId, $previousSchoolYear); if ($previousScore === null && $this->db->tableExists('student_decisions')) { $decisionRow = $this->db->table('student_decisions') ->select('year_score') ->where('student_id', $studentId) ->where('school_year', $previousSchoolYear) ->orderBy('updated_at', 'DESC') ->orderBy('id', 'DESC') ->get() ->getRowArray(); $previousScore = is_numeric($decisionRow['year_score'] ?? null) ? (float)$decisionRow['year_score'] : null; } $previousScore = $previousScore === null ? 0.0 : max(0.0, min(100.0, (float)$previousScore)); $draftModel = new StudentSectionDistributionDraftModel(); $updatedBy = (int)(session()->get('user_id') ?? 0) ?: null; $now = utc_now(); $this->db->transStart(); $draftId = $this->upsertDistributionDraft($draftModel, [ 'student_id' => $studentId, 'class_id' => $targetClassId, 'class_section_id' => $targetSectionId, 'school_year' => $year, 'previous_school_year' => $previousSchoolYear, 'previous_final_score' => $previousScore, 'score_group' => $this->scoreGroup($previousScore), 'status' => 'pending', 'batch_key' => sha1($year . ':' . $studentId . ':' . microtime(true)), 'created_by' => $updatedBy, 'updated_at' => $now, ]); if ($this->db->tableExists('promotion_queue')) { $this->db->table('promotion_queue') ->where('student_id', $studentId) ->where('school_year_to', $year) ->whereIn('status', ['queued', 'assigned']) ->update([ 'to_class_id' => $targetClassId, 'to_class_section_id' => $targetSectionId, 'status' => 'assigned', 'updated_by' => $updatedBy, 'updated_at' => $now, ]); } $this->db->transComplete(); if (!$this->db->transStatus() || $draftId <= 0) { return $json(['ok' => false, 'message' => 'Candidate assignment could not be saved.'], 500); } return $json(['ok' => true, 'message' => 'Candidate assignment saved.']); } catch (\Throwable $e) { return $json(['ok' => false, 'message' => 'Candidate assignment update failed: ' . $e->getMessage()], 500); } } private function distributionCandidates(int $classId, string $year): array { if ($this->isDistributionKgClass($classId, $year)) { return $this->mergeDistributionCandidates( $this->kgDistributionCandidates($classId, $year), $this->currentYearDistributionCandidates($classId, $year) ); } $builder = $this->db->table('promotion_queue pq') ->select('pq.id AS promotion_queue_id, pq.student_id, pq.school_year_from, pq.to_class_id, students.firstname, students.lastname, students.gender, students.age, students.dob, sd.year_score AS decision_score') ->join('students', 'students.id = pq.student_id', 'left') ->join('student_decisions sd', 'sd.student_id = pq.student_id AND sd.school_year = pq.school_year_from', 'left') ->where('pq.school_year_to', $year) ->whereIn('pq.status', ['queued', 'assigned']) ->groupBy('pq.id'); $this->applyDistributionAgeFilter($builder, $year); $rows = $builder ->get() ->getResultArray(); $out = []; $excludedByDecision = $this->distributionExcludedDecisionStudentIds($year); foreach ($rows as $row) { $studentId = (int)($row['student_id'] ?? 0); if ($studentId <= 0 || isset($excludedByDecision[$studentId])) { continue; } $ageAtReference = $this->distributionAgeAtReference($row['dob'] ?? null, $year); $targetClassId = $this->distributionTargetClassIdForStudent( is_numeric($row['to_class_id'] ?? null) ? (int)$row['to_class_id'] : null, $ageAtReference, $year, (string)($row['source_class_name'] ?? '') ); if ($targetClassId !== $classId) { continue; } $score = is_numeric($row['decision_score'] ?? null) ? (float)$row['decision_score'] : $this->previousAverageScore((int)$row['student_id'], (string)($row['school_year_from'] ?? '')); $score = $score === null ? 0.0 : max(0.0, min(100.0, $score)); $row['previous_final_score'] = $score; $row['score_group'] = $this->scoreGroup($score); $row['student_name'] = $this->formatStudentName($row); $row['age_at_reference'] = $ageAtReference; $row['last_year_class_section'] = $this->distributionPreviousClassSectionName( (int)($row['student_id'] ?? 0), $year, (string)($row['school_year_from'] ?? '') ); $out[] = $row; } return $this->mergeDistributionCandidates( $out, $this->decisionDistributionCandidates($classId, $year), $this->currentYearDistributionCandidates($classId, $year), $this->registeredKgDistributionCandidates($classId, $year) ); } private function currentYearDistributionCandidates(int $classId, string $year): array { if ($classId <= 0 || $year === '' || ! $this->db->tableExists('students')) { return []; } $rows = []; if ($this->db->tableExists('student_class')) { $builder = $this->db->table('student_class sc') ->select('0 AS promotion_queue_id, sc.student_id, sc.school_year AS school_year_from, cs.class_id AS source_class_id, cs.class_section_name AS source_class_name, students.firstname, students.lastname, students.gender, students.age, students.dob, students.registration_grade', false) ->join('students', 'students.id = sc.student_id', 'inner') ->join('classSection cs', 'cs.class_section_id = sc.class_section_id AND cs.school_year = sc.school_year', 'left') ->where('sc.school_year', $year) ->where('sc.class_section_id IS NOT NULL', null, false); if ($this->db->fieldExists('is_event_only', 'student_class')) { $builder->groupStart() ->where('sc.is_event_only', 0) ->orWhere('sc.is_event_only', null) ->groupEnd(); } if ($this->db->fieldExists('is_active', 'students')) { $builder->where('students.is_active', 1); } $this->applyDistributionAgeFilter($builder, $year); $rows = array_merge($rows, $builder->get()->getResultArray()); } if ($this->db->tableExists('enrollments')) { $builder = $this->db->table('enrollments e') ->select('0 AS promotion_queue_id, e.student_id, e.school_year AS school_year_from, cs.class_id AS source_class_id, cs.class_section_name AS source_class_name, students.firstname, students.lastname, students.gender, students.age, students.dob, students.registration_grade', false) ->join('students', 'students.id = e.student_id', 'inner') ->join('classSection cs', 'cs.class_section_id = e.class_section_id AND cs.school_year = e.school_year', 'left') ->where('e.school_year', $year) ->whereIn('e.enrollment_status', ['admission under review', 'payment pending', 'enrolled']) ->groupStart() ->where('e.is_withdrawn', 0) ->orWhere('e.is_withdrawn', null) ->groupEnd(); if ($this->db->fieldExists('is_active', 'students')) { $builder->where('students.is_active', 1); } $this->applyDistributionAgeFilter($builder, $year); $rows = array_merge($rows, $builder->get()->getResultArray()); } if ($this->db->fieldExists('school_year', 'students')) { $builder = $this->db->table('students') ->select('0 AS promotion_queue_id, id AS student_id, school_year AS school_year_from, NULL AS source_class_id, registration_grade AS source_class_name, firstname, lastname, gender, age, dob, registration_grade', false) ->where('school_year', $year); if ($this->db->fieldExists('is_active', 'students')) { $builder->where('is_active', 1); } $this->applyDistributionAgeFilter($builder, $year, 'dob'); $rows = array_merge($rows, $builder->get()->getResultArray()); } $previousYear = $this->previousSchoolYearName($year) ?? ''; $excludedByDecision = $this->distributionExcludedDecisionStudentIds($year); $out = []; foreach ($rows as $row) { $studentId = (int)($row['student_id'] ?? 0); if ($studentId <= 0 || isset($excludedByDecision[$studentId])) { continue; } $ageAtReference = $this->distributionAgeAtReference($row['dob'] ?? null, $year); $targetClassId = $this->distributionTargetClassIdForStudent( is_numeric($row['source_class_id'] ?? null) ? (int)$row['source_class_id'] : null, $ageAtReference, $year, (string)($row['source_class_name'] ?? $row['registration_grade'] ?? '') ); if ($targetClassId !== $classId) { continue; } $score = $this->previousAverageScore($studentId, $previousYear); $score = $score === null ? 0.0 : max(0.0, min(100.0, $score)); $out[] = [ 'promotion_queue_id' => 0, 'student_id' => $studentId, 'school_year_from' => $previousYear, 'to_class_id' => $classId, 'student_name' => $this->formatStudentName($row), 'age_at_reference' => $ageAtReference, 'gender' => (string)($row['gender'] ?? ''), 'last_year_class_section' => $this->distributionPreviousClassSectionName($studentId, $year, $previousYear), 'previous_final_score' => $score, 'score_group' => $this->scoreGroup($score), ]; } return $out; } private function mergeDistributionCandidates(array ...$candidateSets): array { $out = []; $seen = []; foreach ($candidateSets as $candidates) { foreach ($candidates as $candidate) { $studentId = (int)($candidate['student_id'] ?? 0); if ($studentId <= 0 || isset($seen[$studentId])) { continue; } $seen[$studentId] = true; $out[] = $candidate; } } return $out; } private function upsertDistributionDraft(StudentSectionDistributionDraftModel $draftModel, array $data): int { $studentId = (int)($data['student_id'] ?? 0); $year = (string)($data['school_year'] ?? ''); if ($studentId <= 0 || $year === '') { return 0; } $existing = $draftModel ->where('student_id', $studentId) ->where('school_year', $year) ->first(); if ($existing) { $draftId = (int)($existing['id'] ?? 0); if ($draftId <= 0) { return 0; } unset($data['created_at']); $data['status'] = 'pending'; $data['applied_at'] = null; $draftModel->update($draftId, $data); return $draftId; } if (empty($data['created_at'])) { $data['created_at'] = $data['updated_at'] ?? utc_now(); } return (int)$draftModel->insert($data); } private function kgDistributionCandidates(int $classId, string $year): array { if ($classId <= 0 || $year === '' || ! $this->db->tableExists('enrollments') || ! $this->db->tableExists('students')) { return []; } $builder = $this->db->table('enrollments e') ->select('0 AS promotion_queue_id, e.student_id, e.school_year AS school_year_from, cs.class_id AS source_class_id, cs.class_section_name AS source_class_name, students.firstname, students.lastname, students.gender, students.age, students.dob, students.registration_grade', false) ->join('students', 'students.id = e.student_id', 'inner') ->join('classSection cs', 'cs.class_section_id = e.class_section_id AND cs.school_year = e.school_year', 'left') ->where('e.school_year', $year) ->whereIn('e.enrollment_status', ['admission under review', 'payment pending', 'enrolled']) ->groupStart() ->where('e.is_withdrawn', 0) ->orWhere('e.is_withdrawn', null) ->groupEnd() ->groupStart() ->where('cs.class_id', $classId) ->orWhereIn('UPPER(students.registration_grade)', ['KG', 'K', 'KINDERGARTEN']) ->groupEnd(); if ($this->db->fieldExists('is_active', 'students')) { $builder->where('students.is_active', 1); } $this->applyDistributionAgeFilter($builder, $year); $rows = $builder ->orderBy('students.lastname', 'ASC') ->orderBy('students.firstname', 'ASC') ->get() ->getResultArray(); $seen = []; $out = []; $excludedByDecision = $this->distributionExcludedDecisionStudentIds($year); foreach ($rows as $row) { $studentId = (int)($row['student_id'] ?? 0); if ($studentId <= 0 || isset($seen[$studentId]) || isset($excludedByDecision[$studentId])) { continue; } $seen[$studentId] = true; $score = $this->previousAverageScore($studentId, $this->previousSchoolYearName($year) ?? ''); $score = $score === null ? 0.0 : max(0.0, min(100.0, $score)); $ageAtReference = $this->distributionAgeAtReference($row['dob'] ?? null, $year); $targetClassId = $this->distributionTargetClassIdForStudent( is_numeric($row['source_class_id'] ?? null) ? (int)$row['source_class_id'] : $classId, $ageAtReference, $year, (string)($row['source_class_name'] ?? $row['registration_grade'] ?? '') ); if ($targetClassId !== $classId) { continue; } $out[] = [ 'promotion_queue_id' => 0, 'student_id' => $studentId, 'school_year_from' => '', 'to_class_id' => $classId, 'student_name' => $this->formatStudentName($row), 'age_at_reference' => $ageAtReference, 'gender' => (string)($row['gender'] ?? ''), 'last_year_class_section' => $this->distributionPreviousClassSectionName($studentId, $year, $this->previousSchoolYearName($year)), 'previous_final_score' => $score, 'score_group' => $this->scoreGroup($score), ]; } foreach ($this->registeredKgDistributionCandidates($classId, $year) as $row) { $studentId = (int)($row['student_id'] ?? 0); if ($studentId <= 0 || isset($seen[$studentId])) { continue; } $seen[$studentId] = true; $out[] = $row; } usort($out, static function (array $a, array $b): int { return strnatcasecmp((string)($a['student_name'] ?? ''), (string)($b['student_name'] ?? '')); }); return $out; } private function registeredKgDistributionCandidates(int $classId, string $year): array { if ($classId <= 0 || $year === '' || ! $this->db->tableExists('students')) { return []; } $builder = $this->db->table('students') ->select('id AS student_id, firstname, lastname, gender, age, dob, registration_grade') ->groupStart() ->where('UPPER(registration_grade)', 'KG') ->orWhere('UPPER(registration_grade)', 'K') ->orWhere('UPPER(registration_grade)', 'KINDERGARTEN') ->groupEnd(); if ($this->db->fieldExists('school_year', 'students')) { $builder->where('school_year', $year); } elseif ($this->db->fieldExists('year_of_registration', 'students') && preg_match('/^(\d{4})/', $year, $matches)) { $registrationYears = [(int)$matches[1]]; $previousYear = $this->previousSchoolYearName($year); if ($previousYear !== null && preg_match('/^(\d{4})/', $previousYear, $previousMatches)) { $registrationYears[] = (int)$previousMatches[1]; } $builder->whereIn('year_of_registration', array_values(array_unique($registrationYears))); } if ($this->db->fieldExists('is_active', 'students')) { $builder->where('is_active', 1); } $this->applyDistributionAgeFilter($builder, $year, 'dob'); $rows = $builder ->orderBy('lastname', 'ASC') ->orderBy('firstname', 'ASC') ->get() ->getResultArray(); $out = []; $excludedByDecision = $this->distributionExcludedDecisionStudentIds($year); foreach ($rows as $row) { $studentId = (int)($row['student_id'] ?? 0); if ($studentId <= 0 || isset($excludedByDecision[$studentId])) { continue; } if (!$this->studentHasOnlyKgPriorPlacement($studentId, $year)) { continue; } $ageAtReference = $this->distributionAgeAtReference($row['dob'] ?? null, $year); $targetClassId = $this->distributionTargetClassIdForStudent( $classId, $ageAtReference, $year, (string)($row['registration_grade'] ?? 'KG') ); if ($targetClassId !== $classId) { continue; } $out[] = [ 'promotion_queue_id' => 0, 'student_id' => $studentId, 'school_year_from' => '', 'to_class_id' => $classId, 'student_name' => $this->formatStudentName($row), 'age_at_reference' => $ageAtReference, 'gender' => (string)($row['gender'] ?? ''), 'last_year_class_section' => $this->distributionPreviousClassSectionName($studentId, $year, $this->previousSchoolYearName($year)), 'previous_final_score' => 0.0, 'score_group' => $this->scoreGroup(0.0), ]; } return $out; } private function studentHasOnlyKgPriorPlacement(int $studentId, string $targetSchoolYear): bool { $previousYear = $this->previousSchoolYearName($targetSchoolYear); if ($studentId <= 0 || $previousYear === null) { return true; } $baseNames = []; if ($this->db->tableExists('student_class')) { $builder = $this->db->table('student_class sc') ->select('cs.class_section_name') ->join('classSection cs', 'cs.class_section_id = sc.class_section_id AND cs.school_year = sc.school_year', 'left') ->where('sc.student_id', $studentId) ->where('sc.school_year', $previousYear) ->where('sc.class_section_id IS NOT NULL', null, false); if ($this->db->fieldExists('is_event_only', 'student_class')) { $builder->groupStart() ->where('sc.is_event_only', 0) ->orWhere('sc.is_event_only', null) ->groupEnd(); } foreach ($builder->get()->getResultArray() as $row) { $baseName = $this->baseClassNameForDistribution((string)($row['class_section_name'] ?? '')); if ($baseName !== '') { $baseNames[$baseName] = true; } } } if ($this->db->tableExists('enrollments')) { $rows = $this->db->table('enrollments e') ->select('cs.class_section_name') ->join('classSection cs', 'cs.class_section_id = e.class_section_id AND cs.school_year = e.school_year', 'left') ->where('e.student_id', $studentId) ->where('e.school_year', $previousYear) ->where('e.class_section_id IS NOT NULL', null, false) ->whereIn('e.enrollment_status', ['admission under review', 'payment pending', 'enrolled']) ->groupStart() ->where('e.is_withdrawn', 0) ->orWhere('e.is_withdrawn', null) ->groupEnd() ->get() ->getResultArray(); foreach ($rows as $row) { $baseName = $this->baseClassNameForDistribution((string)($row['class_section_name'] ?? '')); if ($baseName !== '') { $baseNames[$baseName] = true; } } } if (empty($baseNames)) { return true; } return count($baseNames) === 1 && isset($baseNames['KG']); } private function baseClassNameForDistribution(string $classSectionName): string { return strtoupper(trim(preg_replace('/-.+$/', '', $classSectionName) ?? '')); } private function isDistributionKgClass(int $classId, string $year): bool { if ($classId <= 0) { return false; } $query = $this->classSectionModel ->select('class_section_name') ->where('class_id', $classId) ->where("class_section_name NOT LIKE '%-%'", null, false) ->orderBy('id', 'DESC'); if ($year !== '' && $this->db->fieldExists('school_year', 'classSection')) { $query->where('school_year', $year); } $row = $query->first(); if (!$row && $year !== '' && $this->db->fieldExists('school_year', 'classSection')) { $row = $this->classSectionModel ->select('class_section_name') ->where('class_id', $classId) ->where("class_section_name NOT LIKE '%-%'", null, false) ->orderBy('id', 'DESC') ->first(); } $name = strtoupper(trim((string)($row['class_section_name'] ?? ''))); return in_array($name, ['KG', 'K', 'KINDERGARTEN'], true); } private function letterSectionsForDistribution(int $classId, string $year): array { $this->ensureClassSectionsForYear($year); $query = $this->classSectionModel ->where('class_id', $classId) ->like('class_section_name', '-', 'both') ->orderBy('class_section_name', 'ASC'); if ($year !== '' && $this->db->fieldExists('school_year', 'classSection')) { $query->where('school_year', $year); } $sections = $query->findAll(); if (!empty($sections) || $year === '' || ! $this->db->fieldExists('school_year', 'classSection')) { return $sections; } return $this->classSectionModel->getLetterSectionsByClassId($classId); } private function sectionForDistribution(int $classSectionId, string $year): ?array { if ($classSectionId <= 0) { return null; } $this->ensureClassSectionsForYear($year); $query = $this->classSectionModel ->where('class_section_id', $classSectionId) ->orderBy('id', 'DESC'); if ($year !== '' && $this->db->fieldExists('school_year', 'classSection')) { $query->where('school_year', $year); } $section = $query->first(); if ($section || $year === '' || ! $this->db->fieldExists('school_year', 'classSection')) { return $section ?: null; } return $this->classSectionModel ->where('class_section_id', $classSectionId) ->orderBy('id', 'DESC') ->first(); } private function ensureClassSectionsForYear(string $targetSchoolYear): void { $targetSchoolYear = trim($targetSchoolYear); if ($targetSchoolYear === '' || ! $this->db->tableExists('classSection') || ! $this->db->fieldExists('school_year', 'classSection')) { return; } if ($this->db->table('classSection')->where('school_year', $targetSchoolYear)->countAllResults() > 0) { return; } $sourceSchoolYear = $this->previousSchoolYearName($targetSchoolYear); if ($sourceSchoolYear === null) { return; } $sourceRows = $this->db->table('classSection') ->select('class_id, class_section_id, class_section_name') ->where('school_year', $sourceSchoolYear) ->orderBy('id', 'ASC') ->get() ->getResultArray(); $now = utc_now(); foreach ($sourceRows as $row) { $classSectionId = (int)($row['class_section_id'] ?? 0); if ($classSectionId <= 0) { continue; } $exists = $this->db->table('classSection') ->where('school_year', $targetSchoolYear) ->where('class_section_id', $classSectionId) ->countAllResults(); if ($exists > 0) { continue; } $this->db->table('classSection')->insert([ 'class_id' => (int)($row['class_id'] ?? 0), 'class_section_id' => $classSectionId, 'class_section_name' => (string)($row['class_section_name'] ?? ''), 'school_year' => $targetSchoolYear, 'created_at' => $now, 'updated_at' => $now, ]); } } private function decisionDistributionCandidates(int $classId, string $targetSchoolYear): array { $previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear); if ($previousSchoolYear === null || ! $this->db->tableExists('student_decisions')) { return []; } $builder = $this->db->table('student_decisions sd') ->select('sd.id AS decision_id, sd.student_id, sd.class_section_name, sd.year_score, sd.decision, students.firstname, students.lastname, students.gender, students.age, students.dob') ->join('students', 'students.id = sd.student_id', 'left') ->where('sd.school_year', $previousSchoolYear) ->where('students.is_active', 1) ->orderBy('sd.updated_at', 'DESC') ->orderBy('sd.id', 'DESC'); $this->applyDistributionAgeFilter($builder, $targetSchoolYear); $rows = $builder ->get() ->getResultArray(); $seen = []; $out = []; foreach ($rows as $row) { $studentId = (int)($row['student_id'] ?? 0); if ($studentId <= 0 || isset($seen[$studentId])) { continue; } $seen[$studentId] = true; if (DeliberationDecision::normalize($row['decision'] ?? null) !== DeliberationDecision::PASSED) { continue; } $targetClassId = $this->targetClassIdFromDecision( (string)($row['class_section_name'] ?? ''), (string)($row['decision'] ?? ''), $targetSchoolYear ); $ageAtReference = $this->distributionAgeAtReference($row['dob'] ?? null, $targetSchoolYear); $targetClassId = $this->distributionTargetClassIdForStudent( $targetClassId, $ageAtReference, $targetSchoolYear, (string)($row['class_section_name'] ?? '') ); if ($targetClassId !== $classId) { continue; } $score = is_numeric($row['year_score'] ?? null) ? (float)$row['year_score'] : 0.0; $score = max(0.0, min(100.0, $score)); $out[] = [ 'promotion_queue_id' => 0, 'student_id' => $studentId, 'school_year_from' => $previousSchoolYear, 'to_class_id' => $classId, 'student_name' => $this->formatStudentName($row), 'age_at_reference' => $ageAtReference, 'gender' => (string)($row['gender'] ?? ''), 'last_year_class_section' => $this->distributionPreviousClassSectionName($studentId, $targetSchoolYear, $previousSchoolYear), 'previous_final_score' => $score, 'score_group' => $this->scoreGroup($score), ]; } return $out; } private function targetClassIdFromDecision(string $classSectionName, string $decision, string $targetSchoolYear = ''): ?int { $baseName = strtoupper(trim(preg_replace('/-.+$/', '', $classSectionName) ?? '')); if ($baseName === '') { return null; } $targetBaseName = $baseName; if (DeliberationDecision::normalize($decision) === DeliberationDecision::PASSED) { if ($baseName === 'KG') { $targetBaseName = '1'; } elseif (ctype_digit($baseName)) { $level = (int)$baseName; $targetBaseName = $level >= 10 ? 'YOUTH' : (string)($level + 1); } elseif ($baseName === 'YOUTH') { $targetBaseName = 'YOUTH'; } } $query = $this->classSectionModel ->select('class_id') ->where('UPPER(class_section_name)', $targetBaseName) ->where("class_section_name NOT LIKE '%-%'", null, false) ->orderBy('id', 'DESC'); if ($targetSchoolYear !== '' && $this->db->fieldExists('school_year', 'classSection')) { $query->where('school_year', $targetSchoolYear); } $row = $query->first(); if (!$row && $targetSchoolYear !== '' && $this->db->fieldExists('school_year', 'classSection')) { $row = $this->classSectionModel ->select('class_id') ->where('UPPER(class_section_name)', $targetBaseName) ->where("class_section_name NOT LIKE '%-%'", null, false) ->first(); } return $row ? (int)$row['class_id'] : null; } private function formatStudentName(array $row): string { $name = trim( trim((string)($row['firstname'] ?? '')) . ' ' . trim((string)($row['lastname'] ?? '')) ); return $name !== '' ? $name : 'Student #' . (int)($row['student_id'] ?? 0); } private function previousSchoolYearName(string $schoolYear): ?string { if (preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches) !== 1) { return null; } return ((int)$matches[1] - 1) . '-' . ((int)$matches[2] - 1); } private function previousAverageScore(int $studentId, string $schoolYear): ?float { if ($studentId <= 0 || $schoolYear === '') { return null; } $row = $this->db->table('semester_scores') ->select('AVG(semester_score) AS avg_score') ->where('student_id', $studentId) ->where('school_year', $schoolYear) ->where('semester_score IS NOT NULL', null, false) ->get() ->getRowArray(); return is_numeric($row['avg_score'] ?? null) ? (float)$row['avg_score'] : null; } private function distributionExcludedDecisionStudentIds(string $targetSchoolYear): array { $previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear); if ($previousSchoolYear === null || ! $this->db->tableExists('student_decisions')) { return []; } if (array_key_exists($previousSchoolYear, $this->distributionExcludedDecisionCache)) { return $this->distributionExcludedDecisionCache[$previousSchoolYear]; } $rows = $this->db->table('student_decisions') ->select('student_id, decision') ->where('school_year', $previousSchoolYear) ->orderBy('updated_at', 'DESC') ->orderBy('id', 'DESC') ->get() ->getResultArray(); $seen = []; $excluded = []; foreach ($rows as $row) { $studentId = (int)($row['student_id'] ?? 0); if ($studentId <= 0 || isset($seen[$studentId])) { continue; } $seen[$studentId] = true; if ($this->isDistributionExcludedDecision((string)($row['decision'] ?? ''))) { $excluded[$studentId] = true; } } $this->distributionExcludedDecisionCache[$previousSchoolYear] = $excluded; return $excluded; } private function isDistributionExcludedDecision(string $decision): bool { return DeliberationDecision::normalize($decision) !== DeliberationDecision::PASSED; } private function distributionPreviousClassSectionName(int $studentId, string $targetSchoolYear, ?string $sourceSchoolYear = null): string { if ($studentId <= 0) { return ''; } $previousYear = trim((string)($sourceSchoolYear ?? '')); if ($previousYear === '' || $previousYear === $targetSchoolYear || preg_match('/^\d{4}-\d{4}$/', $previousYear) !== 1) { $previousYear = $this->previousSchoolYearName($targetSchoolYear) ?? ''; } if ($previousYear === '') { return ''; } $cacheKey = $studentId . ':' . $previousYear; if (array_key_exists($cacheKey, $this->distributionPreviousClassSectionCache)) { return $this->distributionPreviousClassSectionCache[$cacheKey]; } $names = []; if ($this->db->tableExists('student_class')) { $builder = $this->db->table('student_class sc') ->select('cs.class_section_name') ->join('classSection cs', 'cs.class_section_id = sc.class_section_id AND cs.school_year = sc.school_year', 'left') ->where('sc.student_id', $studentId) ->where('sc.school_year', $previousYear) ->where('sc.class_section_id IS NOT NULL', null, false); if ($this->db->fieldExists('is_event_only', 'student_class')) { $builder->groupStart() ->where('sc.is_event_only', 0) ->orWhere('sc.is_event_only', null) ->groupEnd(); } $rows = $builder ->orderBy('cs.class_section_name', 'ASC') ->get() ->getResultArray(); foreach ($rows as $row) { $name = trim((string)($row['class_section_name'] ?? '')); if ($name !== '') { $names[$name] = true; } } } if (empty($names) && $this->db->tableExists('enrollments')) { $rows = $this->db->table('enrollments e') ->select('cs.class_section_name') ->join('classSection cs', 'cs.class_section_id = e.class_section_id AND cs.school_year = e.school_year', 'left') ->where('e.student_id', $studentId) ->where('e.school_year', $previousYear) ->where('e.class_section_id IS NOT NULL', null, false) ->whereIn('e.enrollment_status', ['payment pending', 'enrolled']) ->groupStart() ->where('e.is_withdrawn', 0) ->orWhere('e.is_withdrawn', null) ->groupEnd() ->orderBy('cs.class_section_name', 'ASC') ->get() ->getResultArray(); foreach ($rows as $row) { $name = trim((string)($row['class_section_name'] ?? '')); if ($name !== '') { $names[$name] = true; } } } $value = implode(', ', array_keys($names)); $this->distributionPreviousClassSectionCache[$cacheKey] = $value; return $value; } private function scoreGroup(float $score): string { if ($score >= 90) return '90_100'; if ($score >= 80) return '80_89'; if ($score >= 70) return '70_79'; return '69_below'; } private function buildBalancedDistribution(array $students, array $sections, int $minPerSection, ?int $maxPerSection): array { $sectionCount = count($sections); $total = count($students); $baseSize = intdiv($total, $sectionCount); $remainder = $total % $sectionCount; $targetSizes = []; foreach ($sections as $idx => $section) { $targetSizes[$idx] = $baseSize + ($idx < $remainder ? 1 : 0); } $buckets = []; foreach ($sections as $idx => $section) { $buckets[$idx] = [ 'class_section_id' => (int)$section['class_section_id'], 'assigned' => [], 'gender_counts' => ['male' => 0, 'female' => 0, 'other' => 0], ]; } $genderTotals = ['male' => 0, 'female' => 0, 'other' => 0]; foreach ($students as $student) { $genderTotals[$this->distributionGenderKey($student)]++; } $targetGenderCounts = []; foreach ($genderTotals as $gender => $genderTotal) { $baseGenderSize = intdiv($genderTotal, $sectionCount); $genderRemainder = $genderTotal % $sectionCount; $targetGenderCounts[$gender] = array_fill(0, $sectionCount, $baseGenderSize); $order = range(0, $sectionCount - 1); if ($gender === 'female') { $order = array_reverse($order); } foreach ($order as $sectionIdx) { if ($genderRemainder <= 0) { break; } $targetGenderCounts[$gender][$sectionIdx]++; $genderRemainder--; } } $groups = [ '90_100' => ['male' => [], 'female' => [], 'other' => []], '80_89' => ['male' => [], 'female' => [], 'other' => []], '70_79' => ['male' => [], 'female' => [], 'other' => []], '69_below' => ['male' => [], 'female' => [], 'other' => []], ]; foreach ($students as $student) { $scoreGroup = (string)($student['score_group'] ?? '69_below'); if (!isset($groups[$scoreGroup])) { $scoreGroup = '69_below'; } $groups[$scoreGroup][$this->distributionGenderKey($student)][] = $student; } $currentCounts = array_fill(0, $sectionCount, 0); $chooseSection = static function (string $gender) use (&$buckets, &$currentCounts, $targetSizes, $targetGenderCounts, $sectionCount): ?int { $best = null; $bestScore = null; for ($sectionIdx = 0; $sectionIdx < $sectionCount; $sectionIdx++) { if ($currentCounts[$sectionIdx] >= $targetSizes[$sectionIdx]) { continue; } $genderRemaining = ($targetGenderCounts[$gender][$sectionIdx] ?? 0) - ($buckets[$sectionIdx]['gender_counts'][$gender] ?? 0); $totalRemaining = $targetSizes[$sectionIdx] - $currentCounts[$sectionIdx]; $score = [ $genderRemaining > 0 ? 1 : 0, $genderRemaining, $totalRemaining, -$currentCounts[$sectionIdx], -$sectionIdx, ]; if ($bestScore === null || $score > $bestScore) { $best = $sectionIdx; $bestScore = $score; } } return $best; }; foreach ($groups as $genderGroups) { foreach (['male', 'female', 'other'] as $gender) { $groupStudents = $genderGroups[$gender] ?? []; usort($groupStudents, static fn($a, $b): int => ((float)$b['previous_final_score']) <=> ((float)$a['previous_final_score'])); foreach ($groupStudents as $student) { $sectionIdx = $chooseSection($gender); if ($sectionIdx === null) { continue; } $buckets[$sectionIdx]['assigned'][] = $student; $buckets[$sectionIdx]['gender_counts'][$gender]++; $currentCounts[$sectionIdx]++; } } } return $this->balanceDistributionAverages($buckets, $minPerSection, $maxPerSection); } private function distributionGenderKey(array $student): string { $gender = strtolower(trim((string)($student['gender'] ?? ''))); if ($gender === 'female' || $gender === 'f') { return 'female'; } if ($gender === 'male' || $gender === 'm') { return 'male'; } return 'other'; } private function balanceDistributionAverages(array $buckets, int $minPerSection, ?int $maxPerSection): array { for ($i = 0; $i < 50; $i++) { $averages = array_map(function ($bucket): float { if (empty($bucket['assigned'])) return 0.0; $scores = array_map(static fn($student): float => (float)$student['previous_final_score'], $bucket['assigned']); return array_sum($scores) / count($scores); }, $buckets); $highIdx = array_keys($averages, max($averages), true)[0]; $lowIdx = array_keys($averages, min($averages), true)[0]; if (($averages[$highIdx] - $averages[$lowIdx]) <= 1.0) { break; } $best = null; foreach ($buckets[$highIdx]['assigned'] as $hiPos => $hiStudent) { foreach ($buckets[$lowIdx]['assigned'] as $loPos => $loStudent) { if ($hiStudent['score_group'] !== $loStudent['score_group']) continue; if ($this->distributionGenderKey($hiStudent) !== $this->distributionGenderKey($loStudent)) continue; $trial = $buckets; $trial[$highIdx]['assigned'][$hiPos] = $loStudent; $trial[$lowIdx]['assigned'][$loPos] = $hiStudent; $trialAvg = array_map(function ($bucket): float { if (empty($bucket['assigned'])) return 0.0; $scores = array_map(static fn($student): float => (float)$student['previous_final_score'], $bucket['assigned']); return array_sum($scores) / count($scores); }, $trial); $newSpread = max($trialAvg) - min($trialAvg); if ($newSpread < ($averages[$highIdx] - $averages[$lowIdx])) { $best = [$hiPos, $loPos, $newSpread]; } } } if ($best === null) { break; } [$hiPos, $loPos] = $best; $tmp = $buckets[$highIdx]['assigned'][$hiPos]; $buckets[$highIdx]['assigned'][$hiPos] = $buckets[$lowIdx]['assigned'][$loPos]; $buckets[$lowIdx]['assigned'][$loPos] = $tmp; } return $buckets; } /** * API: Return promoted-student totals per base class for the selected year. */ public function promotionTotalsApi() { try { $year = trim((string)($this->request->getGet('school_year') ?? $this->schoolYear)); $includeClassIds = $this->parseIncludedClassIds($this->request->getGet('include_class_ids')); $includeClassIds = array_values(array_unique(array_merge( $includeClassIds, $this->pendingDistributionDraftClassIds($year) ))); $draftTotalsByClassId = $this->pendingDistributionDraftTotalsByClassId($year); $draftStudentIds = $this->pendingDistributionDraftStudentIds($year); // Fetch base sections (no dash) and filter to KG, 1..10, Youth $baseQuery = $this->classSectionModel ->where("class_section_name NOT LIKE '%-%'", null, false) ->orderBy('class_id', 'ASC'); if ($year !== '' && $this->db->fieldExists('school_year', 'classSection')) { $baseQuery->where('school_year', $year); } $bases = $baseQuery->findAll(); if (empty($bases) && $year !== '' && $this->db->fieldExists('school_year', 'classSection')) { $bases = $this->classSectionModel ->where("class_section_name NOT LIKE '%-%'", null, false) ->orderBy('class_id', 'ASC') ->findAll(); } $wanted = []; foreach ($bases as $r) { $nameRaw = (string)($r['class_section_name'] ?? ''); $name = strtolower($nameRaw); // Only KG, 1..10, Youth per request if ($name === 'kg' || $name === 'youth') { $wanted[] = $r; continue; } if (ctype_digit($name)) { $num = (int)$name; if ($num >= 1 && $num <= 10) { $wanted[] = $r; continue; } } if (in_array((int)($r['class_id'] ?? 0), $includeClassIds, true)) { $wanted[] = $r; } } $deduped = []; $seenClassIds = []; foreach ($wanted as $r) { $classId = (int)($r['class_id'] ?? 0); if ($classId <= 0 || isset($seenClassIds[$classId])) { continue; } $seenClassIds[$classId] = true; $deduped[] = $r; } $wanted = $deduped; $out = []; foreach ($wanted as $r) { $classId = (int)$r['class_id']; $candidateStudents = array_values(array_filter( $this->distributionCandidates($classId, $year), static fn(array $student): bool => !isset($draftStudentIds[(int)($student['student_id'] ?? 0)]) )); $total = count($candidateStudents) + ($draftTotalsByClassId[$classId] ?? 0); $out[] = [ 'class_id' => $classId, 'class_section_id' => (int)($r['class_section_id'] ?? 0), 'class_section_name'=> (string)($r['class_section_name'] ?? ''), 'total' => $total, 'students' => $this->distributionCandidateSummaries($candidateStudents, $classId, (string)($r['class_section_name'] ?? '')), 'sections' => $this->savedDistributionSections($classId, $year), ]; } return $this->response->setJSON(['ok' => true, 'year' => $year, 'rows' => $out]); } catch (\Throwable $e) { return $this->response->setStatusCode(500)->setJSON(['ok' => false, 'message' => $e->getMessage()]); } } private function parseIncludedClassIds($raw): array { if ($raw === null || $raw === '') { return []; } if (!is_array($raw)) { $raw = explode(',', (string)$raw); } return array_values(array_unique(array_filter( array_map('intval', $raw), static fn(int $id): bool => $id > 0 ))); } private function distributionCandidateSummaries(array $students, int $classId, string $className): array { $out = []; foreach ($students as $student) { $studentId = (int)($student['student_id'] ?? 0); $studentName = trim((string)($student['student_name'] ?? '')); if ($studentName === '') { $studentName = 'Student #' . $studentId; } $out[] = [ 'draft_id' => 0, 'student_id' => $studentId, 'student_name' => $studentName, 'age_at_reference' => $student['age_at_reference'] ?? null, 'gender' => (string)($student['gender'] ?? ''), 'last_year_class_section' => (string)($student['last_year_class_section'] ?? ''), 'previous_final_score' => $student['previous_final_score'] ?? null, 'class_id' => $classId, 'class_section_id' => 0, 'class_section_name' => $className, ]; } usort($out, static function (array $a, array $b): int { return strnatcasecmp((string)($a['student_name'] ?? ''), (string)($b['student_name'] ?? '')); }); return $out; } private function applyDistributionAgeFilter($builder, string $schoolYear, string $dobColumn = 'students.dob'): void { [$earliestDob, $latestDob] = $this->distributionAgeBirthDateWindow($schoolYear); $builder ->where($dobColumn . ' IS NOT NULL', null, false) ->where($dobColumn . ' >=', $earliestDob) ->where($dobColumn . ' <=', $latestDob); } private function distributionAgeBirthDateWindow(string $schoolYear): array { $reference = $this->distributionAgeReferenceDate($schoolYear); return [ $reference->modify('-18 years +1 day')->format('Y-m-d'), $reference->modify('-5 years')->format('Y-m-d'), ]; } private function distributionAgeReferenceDate(string $schoolYear): \DateTimeImmutable { $schoolYear = trim($schoolYear); if (!preg_match('/^(\d{4})/', $schoolYear, $matches)) { throw new \RuntimeException('School year is required for auto-distribution age filtering.'); } $timezone = new \DateTimeZone((string)(config('School')->attendance['timezone'] ?? user_timezone())); $reference = null; $configuredReference = ''; try { $configuredReference = trim((string)($this->configModel ? $this->configModel->getConfig('date_age_reference') : '')); } catch (\Throwable $e) { $configuredReference = ''; } if ($configuredReference !== '') { $candidate = \DateTimeImmutable::createFromFormat('!Y-m-d', $configuredReference, $timezone); $errors = \DateTimeImmutable::getLastErrors(); $hasErrors = is_array($errors) && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0); if ($candidate !== false && !$hasErrors && $candidate->format('Y') === $matches[1]) { $reference = $candidate; } } if ($reference === null) { $reference = new \DateTimeImmutable($matches[1] . '-09-01', $timezone); } return $reference->setTime(0, 0, 0); } private function distributionAgeAtReference($dob, string $schoolYear): ?int { $dob = trim((string)$dob); if ($dob === '') { return null; } $timezone = new \DateTimeZone((string)(config('School')->attendance['timezone'] ?? user_timezone())); $birthDate = \DateTimeImmutable::createFromFormat('!Y-m-d', $dob, $timezone); $errors = \DateTimeImmutable::getLastErrors(); $hasErrors = is_array($errors) && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0); if ($birthDate === false || $hasErrors) { return null; } $reference = $this->distributionAgeReferenceDate($schoolYear); if ($birthDate > $reference) { return null; } return $birthDate->diff($reference)->y; } private function distributionTargetClassIdForStudent(?int $defaultClassId, ?int $ageAtReference, string $schoolYear, string $sourceClassName = ''): ?int { if ($defaultClassId === null && $ageAtReference !== null && $ageAtReference < 6) { return $this->distributionBaseClassIdByName('KG', $schoolYear); } if ($defaultClassId === null && $ageAtReference === 6) { return $this->distributionBaseClassIdByName('1', $schoolYear); } if ($this->isDistributionKgSource($defaultClassId, $sourceClassName, $schoolYear)) { if ($ageAtReference !== null && $ageAtReference < 6) { return $this->distributionBaseClassIdByName('KG', $schoolYear) ?? $defaultClassId; } if ($ageAtReference !== null && $ageAtReference >= 6) { return $this->distributionBaseClassIdByName('1', $schoolYear) ?? $defaultClassId; } } if ($defaultClassId !== null) { return $defaultClassId; } if ($ageAtReference === 14) { return $this->distributionBaseClassIdByName('9', $schoolYear); } if ($ageAtReference === 15) { return $this->distributionBaseClassIdByName('10', $schoolYear); } if ($ageAtReference === 16 || $ageAtReference === 17) { return $this->distributionBaseClassIdByName('YOUTH', $schoolYear); } return null; } private function isDistributionKgSource(?int $classId, string $sourceClassName, string $schoolYear): bool { $sourceClassName = strtoupper(trim(preg_replace('/-.+$/', '', $sourceClassName) ?? '')); if (in_array($sourceClassName, ['KG', 'K', 'KINDERGARTEN'], true)) { return true; } return $classId !== null && $classId > 0 && $this->isDistributionKgClass($classId, $schoolYear); } private function distributionBaseClassIdByName(string $baseName, string $schoolYear): ?int { $normalized = strtoupper(trim($baseName)); $cacheKey = $schoolYear . ':' . $normalized; if (array_key_exists($cacheKey, $this->distributionBaseClassIdCache)) { return $this->distributionBaseClassIdCache[$cacheKey]; } $query = $this->classSectionModel ->select('class_id') ->where('UPPER(class_section_name)', $normalized) ->where("class_section_name NOT LIKE '%-%'", null, false) ->orderBy('id', 'DESC'); if ($schoolYear !== '' && $this->db->fieldExists('school_year', 'classSection')) { $query->where('school_year', $schoolYear); } $row = $query->first(); if (!$row && $schoolYear !== '' && $this->db->fieldExists('school_year', 'classSection')) { $row = $this->classSectionModel ->select('class_id') ->where('UPPER(class_section_name)', $normalized) ->where("class_section_name NOT LIKE '%-%'", null, false) ->orderBy('id', 'DESC') ->first(); } $this->distributionBaseClassIdCache[$cacheKey] = $row ? (int)$row['class_id'] : null; return $this->distributionBaseClassIdCache[$cacheKey]; } private function pendingDistributionDraftClassIds(string $year): array { if ($year === '' || ! $this->db->tableExists('student_section_distribution_drafts')) { return []; } $builder = $this->db->table('student_section_distribution_drafts') ->join('students', 'students.id = student_section_distribution_drafts.student_id', 'left') ->select('student_section_distribution_drafts.class_id') ->where('student_section_distribution_drafts.school_year', $year) ->where('student_section_distribution_drafts.status', 'pending') ->groupBy('student_section_distribution_drafts.class_id'); $excludedByDecision = $this->distributionExcludedDecisionStudentIds($year); if (!empty($excludedByDecision)) { $builder->whereNotIn('student_section_distribution_drafts.student_id', array_keys($excludedByDecision)); } $this->applyDistributionAgeFilter($builder, $year); $rows = $builder ->get() ->getResultArray(); return array_values(array_unique(array_filter( array_map(static fn(array $row): int => (int)($row['class_id'] ?? 0), $rows), static fn(int $id): bool => $id > 0 ))); } private function pendingDistributionDraftTotalsByClassId(string $year): array { if ($year === '' || ! $this->db->tableExists('student_section_distribution_drafts')) { return []; } $builder = $this->db->table('student_section_distribution_drafts') ->join('students', 'students.id = student_section_distribution_drafts.student_id', 'left') ->select('student_section_distribution_drafts.class_id, COUNT(*) AS total', false) ->where('student_section_distribution_drafts.school_year', $year) ->where('student_section_distribution_drafts.status', 'pending') ->groupBy('student_section_distribution_drafts.class_id'); $excludedByDecision = $this->distributionExcludedDecisionStudentIds($year); if (!empty($excludedByDecision)) { $builder->whereNotIn('student_section_distribution_drafts.student_id', array_keys($excludedByDecision)); } $this->applyDistributionAgeFilter($builder, $year); $rows = $builder ->get() ->getResultArray(); $totals = []; foreach ($rows as $row) { $classId = (int)($row['class_id'] ?? 0); if ($classId > 0) { $totals[$classId] = (int)($row['total'] ?? 0); } } return $totals; } private function pendingDistributionDraftStudentIds(string $year): array { if ($year === '' || ! $this->db->tableExists('student_section_distribution_drafts')) { return []; } $rows = $this->db->table('student_section_distribution_drafts') ->select('student_id') ->where('school_year', $year) ->where('status', 'pending') ->get() ->getResultArray(); $excludedByDecision = $this->distributionExcludedDecisionStudentIds($year); $ids = []; foreach ($rows as $row) { $studentId = (int)($row['student_id'] ?? 0); if ($studentId > 0 && !isset($excludedByDecision[$studentId])) { $ids[$studentId] = true; } } return $ids; } private function savedDistributionSections(int $classId, string $year): array { if (! $this->db->tableExists('student_section_distribution_drafts')) { return []; } $builder = $this->db->table('student_section_distribution_drafts d') ->select('d.id AS draft_id, d.class_id, d.class_section_id, d.previous_school_year, d.previous_final_score, cs.class_section_name, students.firstname, students.lastname, students.gender, students.dob, d.student_id') ->join('classSection cs', 'cs.class_section_id = d.class_section_id AND cs.school_year = d.school_year', 'left') ->join('students', 'students.id = d.student_id', 'left') ->where('d.class_id', $classId) ->where('d.school_year', $year) ->where('d.status', 'pending') ->orderBy('cs.class_section_name', 'ASC') ->orderBy('students.lastname', 'ASC') ->orderBy('students.firstname', 'ASC'); $this->applyDistributionAgeFilter($builder, $year); $rows = $builder ->get() ->getResultArray(); if (empty($rows)) { return []; } $sections = []; $excludedByDecision = $this->distributionExcludedDecisionStudentIds($year); foreach ($rows as $row) { $studentId = (int)($row['student_id'] ?? 0); if ($studentId <= 0 || isset($excludedByDecision[$studentId])) { continue; } $sectionId = (int)($row['class_section_id'] ?? 0); if ($sectionId <= 0) { continue; } if (!isset($sections[$sectionId])) { $sections[$sectionId] = [ 'class_id' => (int)($row['class_id'] ?? $classId), 'class_section_id' => $sectionId, 'class_section_name' => (string)($row['class_section_name'] ?? $sectionId), 'total' => 0, 'male' => 0, 'female' => 0, 'score_total' => 0.0, 'score_count' => 0, 'student_names' => [], 'student_assignments' => [], ]; } $name = trim(trim((string)($row['firstname'] ?? '')) . ' ' . trim((string)($row['lastname'] ?? ''))); if ($name === '') { $name = 'Student #' . $studentId; } $gender = strtolower((string)($row['gender'] ?? '')); if ($gender === 'female') { $sections[$sectionId]['female']++; } else { $sections[$sectionId]['male']++; } if (is_numeric($row['previous_final_score'] ?? null)) { $sections[$sectionId]['score_total'] += (float)$row['previous_final_score']; $sections[$sectionId]['score_count']++; } $sections[$sectionId]['student_names'][] = $name; $sections[$sectionId]['student_assignments'][] = [ 'draft_id' => (int)($row['draft_id'] ?? 0), 'student_id' => $studentId, 'student_name' => $name, 'age_at_reference' => $this->distributionAgeAtReference($row['dob'] ?? null, $year), 'gender' => (string)($row['gender'] ?? ''), 'previous_final_score' => is_numeric($row['previous_final_score'] ?? null) ? (float)$row['previous_final_score'] : null, 'last_year_class_section' => $this->distributionPreviousClassSectionName( $studentId, $year, (string)($row['previous_school_year'] ?? '') ), 'class_id' => (int)($row['class_id'] ?? $classId), 'class_section_id' => $sectionId, ]; $sections[$sectionId]['total']++; } foreach ($sections as &$section) { $scoreCount = (int)($section['score_count'] ?? 0); $section['average_score'] = $scoreCount > 0 ? round((float)$section['score_total'] / $scoreCount, 2) : null; unset($section['score_total'], $section['score_count']); } unset($section); return array_values($sections); } /** * POST /students/update/{id} */ public function editStudentData(?int $id = null) { $request = $this->request; if ($id === null) { $id = (int) $request->getPost('id'); } if (!$id) { return redirect()->back()->with('error', 'Invalid student ID.'); } // Keep original post to detect presence of keys $rawPost = $request->getPost(); // Validate all columns we edit (except id) $rules = [ 'school_id' => "permit_empty|alpha_numeric_punct|max_length[100]|is_unique[students.school_id,id,{$id}]", 'firstname' => 'required|regex_match[/^[A-Za-z\s\-]{2,30}$/]', 'lastname' => 'required|regex_match[/^[A-Za-z\s\-]{2,30}$/]', 'dob' => 'required|valid_date[Y-m-d]', 'age' => 'permit_empty|integer', // we compute it anyway 'gender' => 'required|in_list[Male,Female,Other]', 'registration_grade' => 'required|max_length[50]', 'photo_consent' => 'permit_empty|in_list[0,1]', 'parent_id' => 'required|integer|greater_than[0]', 'registration_date' => 'permit_empty', // accept Y-m-d or Y-m-d\TH:i; parse manually 'tuition_paid' => 'permit_empty|in_list[0,1]', 'year_of_registration' => 'permit_empty|regex_match[/^\d{4}(-\d{4})?$/]', 'school_year' => 'permit_empty|regex_match[/^\d{4}-\d{4}$/]', 'rfid_tag' => 'permit_empty|max_length[100]', 'semester' => 'permit_empty|in_list[Fall,Spring,Summer]', 'is_new' => 'required|in_list[0,1]', // Free-text lists; parsed later 'medical_conditions' => 'permit_empty', 'allergies' => 'permit_empty', // Touch flags from UI; used to decide whether to sync health lists 'medical_touched' => 'permit_empty|in_list[0,1]', 'allergies_touched' => 'permit_empty|in_list[0,1]', ]; if (!$this->validate($rules)) { return redirect()->back() ->withInput() ->with('error', 'Please correct the highlighted errors.') ->with('errors', $this->validator->getErrors()); } // Gather & sanitize inputs $in = [ 'school_id' => trim((string) $request->getPost('school_id')), 'firstname' => $this->titleCase((string) $request->getPost('firstname')), 'lastname' => $this->titleCase((string) $request->getPost('lastname')), 'dob' => trim((string) $request->getPost('dob')), // Y-m-d 'gender' => trim((string) $request->getPost('gender')), 'registration_grade' => trim((string) $request->getPost('registration_grade')), 'photo_consent' => (string) $request->getPost('photo_consent', FILTER_SANITIZE_NUMBER_INT), 'parent_id' => (string) $request->getPost('parent_id', FILTER_SANITIZE_NUMBER_INT), 'registration_date' => trim((string) $request->getPost('registration_date')), // '' or 'Y-m-d' or 'Y-m-d\TH:i' 'tuition_paid' => (string) $request->getPost('tuition_paid', FILTER_SANITIZE_NUMBER_INT), 'year_of_registration' => trim((string) $request->getPost('year_of_registration')), 'school_year' => trim((string) $request->getPost('school_year')), 'rfid_tag' => trim((string) $request->getPost('rfid_tag')), 'semester' => trim((string) $request->getPost('semester')), 'is_new' => (string) $request->getPost('is_new', FILTER_SANITIZE_NUMBER_INT), // raw lists (may be missing from POST entirely) 'medical_conditions' => (string) ($rawPost['medical_conditions'] ?? ''), 'allergies' => (string) ($rawPost['allergies'] ?? ''), // touch flags ("1" when user interacted) 'medical_touched' => (string) $request->getPost('medical_touched', FILTER_SANITIZE_NUMBER_INT), 'allergies_touched' => (string) $request->getPost('allergies_touched', FILTER_SANITIZE_NUMBER_INT), ]; $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); $tzLocal = new \DateTimeZone($tzName); $tzUtc = new \DateTimeZone('UTC'); try { // DOB -> Y-m-d and compute age $dob = \DateTimeImmutable::createFromFormat('Y-m-d', $in['dob'], $tzLocal); if ($dob === false) { throw new \RuntimeException('Invalid date of birth.'); } $dobStr = $dob->format('Y-m-d'); $ageSchoolYear = $in['school_year'] ?: $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); $age = $this->distributionAgeAtReference($dobStr, $ageSchoolYear); if ($age === null) { throw new \RuntimeException('DOB results in invalid age for the selected school year.'); } // registration_date: accept date or datetime-local; store as UTC DATETIME $regDateStr = null; if ($in['registration_date'] !== '') { $rd = null; foreach (['Y-m-d\TH:i', 'Y-m-d'] as $fmt) { $tmp = \DateTimeImmutable::createFromFormat($fmt, $in['registration_date'], $tzLocal); if ($tmp !== false) { $rd = $tmp; break; } } if ($rd === null) { throw new \RuntimeException('Invalid registration date.'); } // normalize to full minute precision in UTC $regDateStr = $rd->setTimezone($tzUtc)->format('Y-m-d H:i:00'); } // Build payload strictly to StudentModel::$allowedFields $studentData = [ 'school_id' => $in['school_id'] ?: null, 'firstname' => $in['firstname'], 'lastname' => $in['lastname'], 'dob' => $dobStr, 'age' => $age, 'gender' => $in['gender'], 'registration_grade' => $in['registration_grade'], 'photo_consent' => (int) ($in['photo_consent'] === '1'), 'parent_id' => (int) $in['parent_id'], 'registration_date' => $regDateStr, // nullable 'tuition_paid' => (int) ($in['tuition_paid'] === '1'), 'year_of_registration' => $in['year_of_registration'] ?: null, 'school_year' => $in['school_year'] ?: null, 'rfid_tag' => $in['rfid_tag'] ?: null, 'semester' => $in['semester'] ?: null, ]; // Normalize health lists (server-side safety) $normConditions = $this->normalizeHealthList($in['medical_conditions'], 100); // -> condition_name $normAllergies = $this->normalizeHealthList($in['allergies'], 100); // -> allergy $this->db->transStart(); $row = $this->studentModel->find($id); if (!$row) { $this->db->transRollback(); return redirect()->back()->with('error', 'Student not found.'); } if (!$this->studentModel->update($id, $studentData)) { $this->db->transRollback(); return redirect()->back() ->withInput() ->with('error', 'Could not update student.') ->with('errors', $this->studentModel->errors() ?: []); } $statusYear = $in['school_year'] !== '' ? $in['school_year'] : (string) ($this->currentSchoolYearName((string) ($this->schoolYear ?? '')) ?: ''); if ($statusYear !== '') { service('studentYearStatus')->upsert($id, $statusYear, $in['is_new'] === '1'); } // Only sync health lists when user actually changed them (touched=1) // Fallback: if no touch flag but a non-empty value was explicitly posted, also sync. $medTouched = ($in['medical_touched'] === '1'); $allTouched = ($in['allergies_touched'] === '1'); if ($medTouched || (array_key_exists('medical_conditions', $rawPost) && trim($in['medical_conditions']) !== '')) { $this->syncHealthList($id, $this->conditionModel, 'condition_name', $normConditions); } if ($allTouched || (array_key_exists('allergies', $rawPost) && trim($in['allergies']) !== '')) { $this->syncHealthList($id, $this->allergyModel, 'allergy', $normAllergies); } $this->db->transComplete(); if ($this->db->transStatus() === false) { return redirect()->back()->withInput()->with('error', 'Transaction failed while updating student.'); } return redirect()->to('/administrator/student_profiles')->with('success', 'Student updated successfully.'); } catch (\CodeIgniter\Database\Exceptions\DataException $e) { log_message('error', '[Students:update] DataException: {msg}', ['msg' => $e->getMessage()]); return redirect()->back()->withInput()->with('error', 'Database error while updating student.'); } catch (\Throwable $e) { log_message('error', '[Students:update] Exception: {msg}', ['msg' => $e->getMessage()]); return redirect()->back()->withInput()->with('error', $e->getMessage()); } } /** * Normalize a comma/semicolon/newline list: * - Trim & collapse spaces * - Drop placeholders like "none", "n/a", "na", "null", "nil" * - Deduplicate case-insensitively * - Truncate to $maxLen */ private function normalizeHealthList(?string $s, int $maxLen = 100): array { $s = (string)$s; if ($s === '') return []; // empty means "no change" unless key missing (handled by caller) $parts = preg_split('/[,\n;]+/u', $s, -1, PREG_SPLIT_NO_EMPTY) ?: []; $outAssoc = []; foreach ($parts as $p) { $v = trim(preg_replace('/\s+/u', ' ', $p)); if ($v === '') continue; if (preg_match('/^(none|n\/a|na|null|nil|no)$/i', $v)) continue; $v = mb_substr($v, 0, $maxLen, 'UTF-8'); $outAssoc[mb_strtolower($v, 'UTF-8')] = $v; // dedupe case-insensitively } return array_values($outAssoc); } /** * Diff-sync helper: * - Reads existing rows for student * - Deletes removed values * - Inserts only new values * Requires $model to have columns: id, student_id, and $field (e.g., allergy / condition_name) */ private function syncHealthList(int $studentId, \CodeIgniter\Model $model, string $field, array $newValues): void { // Fetch current values $rows = $model->where('student_id', $studentId)->select("id, {$field}")->findAll(); $current = []; $byId = []; foreach ($rows as $r) { $val = (string)($r[$field] ?? ''); $key = mb_strtolower(trim($val), 'UTF-8'); if ($key === '') continue; $current[$key] = $val; $byId[$key] = (int)$r['id']; } // Build new set $incoming = []; foreach ($newValues as $v) { $key = mb_strtolower(trim($v), 'UTF-8'); if ($key === '') continue; $incoming[$key] = $v; } // Compute diffs $toDeleteKeys = array_diff(array_keys($current), array_keys($incoming)); $toInsertKeys = array_diff(array_keys($incoming), array_keys($current)); // Delete removed if (!empty($toDeleteKeys)) { $ids = array_map(fn($k) => $byId[$k], $toDeleteKeys); if (!empty($ids)) { $model->whereIn('id', $ids)->delete(); } } // Insert new if (!empty($toInsertKeys)) { $batch = []; foreach ($toInsertKeys as $k) { $batch[] = [ 'student_id' => $studentId, $field => $incoming[$k], ]; } if (!empty($batch)) { // Use insertBatch; ignore duplicates if unique index exists $model->insertBatch($batch); } } } /** * Split a free-text list by commas/semicolons/newlines, trim items, * de-duplicate (case-insensitive), and drop empties. Truncates to $maxLen. */ private function parseList(string $input, int $maxLen = 100): array { if ($input === '') return []; $parts = preg_split('/[,\n;]+/u', $input); if (!$parts) return []; $seen = []; $out = []; foreach ($parts as $raw) { $item = trim(strip_tags($raw)); if ($item === '') continue; // Case-insensitive de-dup $key = mb_strtolower($item, 'UTF-8'); if (isset($seen[$key])) continue; $seen[$key] = true; // Enforce maxLen to align with model validation $out[] = mb_substr($item, 0, $maxLen, 'UTF-8'); } return $out; } private function titleCase(string $s): string { $s = strip_tags($s); $s = mb_strtolower($s ?: '', 'UTF-8'); return mb_convert_case($s, MB_CASE_TITLE_SIMPLE, 'UTF-8'); } public function scoreCard() { $studentId = (int)($this->request->getPost('student_id') ?? 0); if ($studentId <= 0) { return redirect()->back()->with('error', 'Invalid student id.'); } $roleRaw = session()->get('role'); if (is_array($roleRaw)) { $roleRaw = $roleRaw[0] ?? 'guest'; } $role = strtolower((string)($roleRaw ?? 'guest')); $isAdminScoreCardAccess = in_array($role, ['administrator', 'admin', 'principal', 'administrative staff'], true); $student = $this->studentModel ->select('id, firstname, lastname, school_id, is_active') ->where('id', $studentId) ->first(); if (!$student) { return $this->response->setStatusCode(404)->setBody('