3085 lines
131 KiB
PHP
3085 lines
131 KiB
PHP
<?php
|
|
|
|
namespace App\Controllers\View;
|
|
|
|
use App\Controllers\BaseController;
|
|
use App\Models\UserModel; // Ensure this is the correct namespace for UserModel
|
|
use App\Models\StudentModel; // Ensure this is the correct namespace for StudentModel
|
|
use App\Models\StudentClassModel;
|
|
use App\Models\ClassSectionModel;
|
|
use App\Models\EmergencyContactModel;
|
|
use App\Models\EnrollmentModel;
|
|
use App\Models\ConfigurationModel;
|
|
use App\Models\StudentSectionDistributionDraftModel;
|
|
use App\Models\StudentAllergyModel;
|
|
use App\Models\StudentMedicalConditionModel;
|
|
use App\Support\Enrollment\DeliberationDecision;
|
|
use CodeIgniter\Database\Exceptions\DataException;
|
|
use Throwable;
|
|
|
|
class StudentController extends BaseController
|
|
{
|
|
protected $configModel;
|
|
protected $semester;
|
|
protected $schoolYear;
|
|
protected $teacherClassModel;
|
|
protected $studentClassModel;
|
|
protected $studentModel;
|
|
protected $teacherModel;
|
|
protected $userModel;
|
|
protected $db;
|
|
protected $conditionModel;
|
|
protected $allergyModel;
|
|
protected $classSectionModel;
|
|
protected $emergencyContact;
|
|
protected $enrollmentModel;
|
|
protected $distributionBaseClassIdCache = [];
|
|
protected $distributionPreviousClassSectionCache = [];
|
|
protected $distributionExcludedDecisionCache = [];
|
|
|
|
public function __construct()
|
|
{
|
|
// Load the database service
|
|
$this->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('<div class="p-3 text-danger">Student not found.</div>');
|
|
}
|
|
if (!$isAdminScoreCardAccess && (int)($student['is_active'] ?? 0) !== 1) {
|
|
return redirect()->to('/student/score-card/list')->with('error', 'Student score card is not available.');
|
|
}
|
|
|
|
$rows = $this->db->table('semester_scores ss')
|
|
->select([
|
|
'ss.school_year',
|
|
'ss.semester',
|
|
'ss.homework_avg',
|
|
'ss.project_avg',
|
|
'ss.participation_score',
|
|
'ss.quiz_avg',
|
|
'ss.test_avg',
|
|
'ss.attendance_score',
|
|
'ss.ptap_score',
|
|
'ss.midterm_exam_score',
|
|
'ss.semester_score',
|
|
'cs.class_section_name',
|
|
])
|
|
->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
|
|
->where('ss.student_id', $studentId)
|
|
->get()
|
|
->getResultArray();
|
|
|
|
$semesterOrder = static function (string $semester): int {
|
|
$s = strtolower(trim($semester));
|
|
if ($s === 'fall') return 1;
|
|
if ($s === 'spring') return 2;
|
|
return 3;
|
|
};
|
|
|
|
usort($rows, static function (array $a, array $b) use ($semesterOrder) {
|
|
$ay = (string)($a['school_year'] ?? '');
|
|
$by = (string)($b['school_year'] ?? '');
|
|
if ($ay !== $by) return strcmp($by, $ay);
|
|
$as = $semesterOrder((string)($a['semester'] ?? ''));
|
|
$bs = $semesterOrder((string)($b['semester'] ?? ''));
|
|
if ($as !== $bs) return $as <=> $bs;
|
|
return 0;
|
|
});
|
|
|
|
$yearScoreMap = [];
|
|
foreach ($rows as $r) {
|
|
$year = (string)($r['school_year'] ?? '');
|
|
if ($year === '') continue;
|
|
$semKey = strtolower(trim((string)($r['semester'] ?? '')));
|
|
$scoreVal = is_numeric($r['semester_score'] ?? null) ? (float)$r['semester_score'] : null;
|
|
if ($scoreVal === null) continue;
|
|
if (!isset($yearScoreMap[$year])) $yearScoreMap[$year] = [];
|
|
$yearScoreMap[$year][$semKey] = $scoreVal;
|
|
}
|
|
|
|
foreach ($yearScoreMap as $year => $vals) {
|
|
$scores = [];
|
|
if (isset($vals['fall'])) $scores[] = $vals['fall'];
|
|
if (isset($vals['spring'])) $scores[] = $vals['spring'];
|
|
if (empty($scores)) {
|
|
$yearScoreMap[$year]['avg'] = null;
|
|
} else {
|
|
$yearScoreMap[$year]['avg'] = round(array_sum($scores) / count($scores), 1);
|
|
}
|
|
}
|
|
|
|
$commentRows = $this->db->table('score_comments')
|
|
->select('school_year, semester, score_type, comment, comment_review, created_at')
|
|
->where('student_id', $studentId)
|
|
->orderBy('created_at', 'DESC')
|
|
->get()
|
|
->getResultArray();
|
|
|
|
$commentMap = [];
|
|
foreach ($commentRows as $row) {
|
|
$year = (string)($row['school_year'] ?? '');
|
|
$semester = strtolower(trim((string)($row['semester'] ?? '')));
|
|
$typeRaw = strtolower(trim((string)($row['score_type'] ?? '')));
|
|
if ($year === '' || $typeRaw === '') continue;
|
|
if ($typeRaw === 'attendance_comment') $typeRaw = 'attendance';
|
|
$commentVal = trim((string)($row['comment'] ?? ''));
|
|
$reviewVal = trim((string)($row['comment_review'] ?? ''));
|
|
if (in_array($typeRaw, ['midterm', 'final', 'ptap'], true)) {
|
|
$commentVal = $reviewVal !== '' ? $reviewVal : $commentVal;
|
|
} elseif ($typeRaw === 'attendance' && $commentVal === '' && $reviewVal !== '') {
|
|
$commentVal = $reviewVal;
|
|
}
|
|
if ($commentVal === '') continue;
|
|
|
|
$key = $year . '|' . $semester;
|
|
if (!isset($commentMap[$key])) $commentMap[$key] = [];
|
|
if (!isset($commentMap[$key][$typeRaw])) {
|
|
$commentMap[$key][$typeRaw] = $commentVal;
|
|
}
|
|
}
|
|
|
|
foreach ($rows as &$r) {
|
|
$year = (string)($r['school_year'] ?? '');
|
|
$semKey = strtolower(trim((string)($r['semester'] ?? '')));
|
|
$key = $year . '|' . $semKey;
|
|
$r['comments'] = $commentMap[$key] ?? [];
|
|
$r['year_score'] = $yearScoreMap[$year]['avg'] ?? null;
|
|
}
|
|
unset($r);
|
|
|
|
$rowsByYear = [];
|
|
foreach ($rows as $r) {
|
|
$year = (string)($r['school_year'] ?? '');
|
|
if ($year === '') continue;
|
|
$semKey = strtolower(trim((string)($r['semester'] ?? '')));
|
|
if ($semKey === '') continue;
|
|
if (!isset($rowsByYear[$year])) $rowsByYear[$year] = [];
|
|
$rowsByYear[$year][$semKey] = $r;
|
|
}
|
|
|
|
$expandedRows = [];
|
|
foreach ($rowsByYear as $year => $bySem) {
|
|
$fallbackGrade = $bySem['fall']['class_section_name'] ?? ($bySem['spring']['class_section_name'] ?? null);
|
|
foreach (['fall', 'spring'] as $semKey) {
|
|
if (isset($bySem[$semKey])) {
|
|
$expandedRows[] = $bySem[$semKey];
|
|
continue;
|
|
}
|
|
$expandedRows[] = [
|
|
'school_year' => $year,
|
|
'semester' => ucfirst($semKey),
|
|
'homework_avg' => null,
|
|
'project_avg' => null,
|
|
'participation_score' => null,
|
|
'quiz_avg' => null,
|
|
'test_avg' => null,
|
|
'attendance_score' => null,
|
|
'ptap_score' => null,
|
|
'midterm_exam_score' => null,
|
|
'semester_score' => null,
|
|
'class_section_name' => $fallbackGrade,
|
|
'comments' => [],
|
|
'year_score' => $yearScoreMap[$year]['avg'] ?? null,
|
|
];
|
|
}
|
|
}
|
|
|
|
if (!empty($expandedRows)) {
|
|
$rows = $expandedRows;
|
|
}
|
|
|
|
return view('student/score_card', [
|
|
'student' => $student,
|
|
'rows' => $rows,
|
|
]);
|
|
}
|
|
|
|
public function scoreCardIndex()
|
|
{
|
|
return redirect()->to('/student/score-card/list');
|
|
}
|
|
|
|
public function scoreCardAdmin()
|
|
{
|
|
$query = trim((string)($this->request->getGet('q') ?? ''));
|
|
$students = [];
|
|
$suggestions = [];
|
|
|
|
if ($query !== '') {
|
|
$schoolIdPart = '';
|
|
$namePart = $query;
|
|
if (preg_match('/^\s*([^-\s]+)\s*-\s*(.+)\s*$/', $query, $m)) {
|
|
$schoolIdPart = trim((string)$m[1]);
|
|
$namePart = trim((string)$m[2]);
|
|
}
|
|
$students = $this->studentModel
|
|
->select('id, school_id, firstname, lastname, is_active')
|
|
->groupStart()
|
|
->like('firstname', $namePart)
|
|
->orLike('lastname', $namePart)
|
|
->orLike('school_id', $query)
|
|
->groupEnd()
|
|
->orderBy('lastname', 'ASC')
|
|
->orderBy('firstname', 'ASC')
|
|
->limit(50)
|
|
->findAll();
|
|
if ($schoolIdPart !== '' && empty($students)) {
|
|
$students = $this->studentModel
|
|
->select('id, school_id, firstname, lastname, is_active')
|
|
->groupStart()
|
|
->like('school_id', $schoolIdPart)
|
|
->orLike("CONCAT_WS(' ', firstname, lastname)", $namePart, 'both', null, false)
|
|
->groupEnd()
|
|
->orderBy('lastname', 'ASC')
|
|
->orderBy('firstname', 'ASC')
|
|
->limit(50)
|
|
->findAll();
|
|
}
|
|
}
|
|
|
|
try {
|
|
$suggestions = $this->studentModel
|
|
->select('id, school_id, firstname, lastname')
|
|
->where('is_active', 1)
|
|
->orderBy('lastname', 'ASC')
|
|
->orderBy('firstname', 'ASC')
|
|
->limit(1000)
|
|
->findAll();
|
|
} catch (\Throwable $e) {
|
|
$suggestions = [];
|
|
}
|
|
|
|
return view('admin/student_score_card', [
|
|
'query' => $query,
|
|
'students' => $students,
|
|
'suggestions' => $suggestions,
|
|
]);
|
|
}
|
|
|
|
public function scoreCardList()
|
|
{
|
|
$roleRaw = session()->get('role');
|
|
if (is_array($roleRaw)) {
|
|
$roleRaw = $roleRaw[0] ?? 'guest';
|
|
}
|
|
$role = strtolower((string)($roleRaw ?? 'guest'));
|
|
|
|
$students = [];
|
|
if (in_array($role, ['parent', 'parent_dashboard'], true)) {
|
|
$schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
|
$parentId = (int)(session()->get('user_id') ?? 0);
|
|
if ($parentId > 0 && $schoolYear !== '') {
|
|
$students = $this->studentModel->getActiveByParentAndYear($parentId, $schoolYear);
|
|
}
|
|
} elseif (in_array($role, ['teacher', 'teacher_assistant', 'teacher_dashboard'], true)) {
|
|
$schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
|
$classSectionId = (int)(session()->get('class_section_id') ?? 0);
|
|
|
|
if ($classSectionId > 0 && !empty($schoolYear)) {
|
|
$students = $this->studentModel->getActiveByClassAndYear($classSectionId, $schoolYear);
|
|
}
|
|
|
|
if (empty($students)) {
|
|
$userId = (int)(session()->get('user_id') ?? 0);
|
|
if ($userId > 0 && !empty($schoolYear)) {
|
|
$db = \Config\Database::connect();
|
|
$sectionRows = $db->table('teacher_class')
|
|
->select('class_section_id')
|
|
->where('teacher_id', $userId)
|
|
->where('school_year', (string)$schoolYear)
|
|
->get()
|
|
->getResultArray();
|
|
$sectionIds = array_values(array_filter(array_unique(array_map(
|
|
static fn($r) => (int)($r['class_section_id'] ?? 0),
|
|
$sectionRows
|
|
)), static fn($v) => $v > 0));
|
|
if (!empty($sectionIds)) {
|
|
$studentClassModel = new \App\Models\StudentClassModel();
|
|
$rows = $studentClassModel->getStudentsByClassSectionIds($sectionIds, $schoolYear);
|
|
$unique = [];
|
|
foreach ($rows as $r) {
|
|
$sid = (int)($r['student_id'] ?? 0);
|
|
if ($sid > 0 && (int)($r['is_active'] ?? 0) === 1) {
|
|
$unique[$sid] = [
|
|
'id' => $sid,
|
|
'school_id' => $r['school_id'] ?? '',
|
|
'firstname' => $r['firstname'] ?? '',
|
|
'lastname' => $r['lastname'] ?? '',
|
|
];
|
|
}
|
|
}
|
|
$students = array_values($unique);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
return redirect()->to('/login')->with('error', 'Access denied.');
|
|
}
|
|
|
|
return view('student/score_card_list', [
|
|
'students' => $students,
|
|
'role' => $role,
|
|
]);
|
|
}
|
|
}
|