fix enrollment logic, add financial aid, fix class distribution
Tests / PHPUnit (push) Failing after 1m6s

This commit is contained in:
root
2026-08-15 15:07:16 -04:00
parent c12bb59372
commit 4603d9ced2
95 changed files with 6892 additions and 1295 deletions
@@ -15,7 +15,7 @@ class AuthorizedUsersController extends ResourceController
protected $userModel;
protected $authorizedUserModel;
public function __construct(private \CodeIgniter\HTTP\IncomingRequest $request)
public function __construct()
{
$this->userModel = new UserModel();
$this->authorizedUserModel = new AuthorizedUserModel();
+1 -1
View File
@@ -18,7 +18,7 @@ class ClassController extends BaseController
protected $db;
public function __construct(private \CodeIgniter\HTTP\IncomingRequest $request)
public function __construct()
{
$this->db = \Config\Database::connect();
$this->classsectionModel = new ClassSectionModel();
+1 -1
View File
@@ -6,7 +6,7 @@ use App\Controllers\BaseController;
class ContactController extends BaseController
{
public function __construct(private \CodeIgniter\HTTP\IncomingRequest $request)
public function __construct()
{
helper('form'); // Load the form helper
}
@@ -26,8 +26,14 @@ class EnrollmentAdminController extends BaseController
$status = trim((string) ($this->request->getGet('status') ?? 'open'));
$flagType = trim((string) ($this->request->getGet('flag_type') ?? ''));
$assignedTo = trim((string) ($this->request->getGet('assigned_to') ?? ''));
$exceptionParentId = (int) ($this->request->getGet('exception_parent_id') ?? 0);
$canManageExceptions = $this->canManageEnrollmentExceptions();
service('enrollmentTransition')->syncDashboardBlockageFlags($schoolYear);
$flags = $this->enrollmentFlags($schoolYear, $status, $flagType, $assignedTo);
$openFlags = ($status === 'open' && $flagType === '' && $assignedTo === '')
? $flags
: $this->enrollmentFlags($schoolYear, 'open', '', '');
return view('administrator/enrollment_admin_dashboard', [
'flags' => $flags,
@@ -39,11 +45,19 @@ class EnrollmentAdminController extends BaseController
'schoolYears' => $this->schoolYears(),
'classSections' => $this->classSections($schoolYear),
'admins' => $this->adminUsers(),
'enrollmentFollowups' => $this->enrollmentFollowups($schoolYear),
'enrollmentFollowups' => $this->enrollmentFollowups($schoolYear, $openFlags),
'auditRows' => $this->auditRows($schoolYear),
'activeExceptions' => $canManageExceptions ? $this->enrollmentExceptions($schoolYear) : [],
'exceptionNeeded' => $this->exceptionNeededFromFlags($openFlags),
'canManageEnrollmentExceptions' => $canManageExceptions,
'exceptionFamilies' => $this->exceptionFamilies(),
'exceptionReasonCodes' => $this->exceptionReasonCodes(),
'exceptionSelectedParentId' => $exceptionParentId,
'exceptionPreview' => $this->exceptionPreview($exceptionParentId, $schoolYear),
'launchState' => $this->launchState($schoolYear),
'previewParentId' => $this->firstParentWithStudents(),
'emailExamples' => service('enrollmentRegistrationEmail')->previewExamplesForSchoolYear($schoolYear),
'openFlagCount' => $this->openFlagCount($schoolYear),
]);
}
@@ -202,28 +216,257 @@ class EnrollmentAdminController extends BaseController
public function approveException(int $id)
{
try {
if (! $this->canManageEnrollmentExceptions()) {
return redirect()->back()->with('error', 'You do not have permission to manage enrollment exceptions.');
}
$flag = $this->requireFlag($id);
$reason = trim((string) ($this->request->getPost('reason') ?? ''));
if ($reason === '') {
return redirect()->back()->with('error', 'Approval reason is required.');
}
$parentId = $this->parentIdForStudent((int) $flag['student_id'], (string) $flag['school_year']);
if ($parentId <= 0) {
return redirect()->back()->with('error', 'Unable to create exception because no linked parent was found.');
}
$ruleCodes = $this->ruleCodesForFlag((string) $flag['flag_type']);
$now = date('Y-m-d H:i:s');
$expiresAt = date('Y-m-d H:i:s', strtotime('+30 days'));
$this->db->transStart();
if ($this->db->tableExists('enrollment_exceptions')) {
$existing = $this->db->table('enrollment_exceptions')
->where('parent_id', $parentId)
->where('student_id', (int) $flag['student_id'])
->where('school_year', (string) $flag['school_year'])
->where('status', 'active')
->limit(1)
->get()
->getRowArray();
$payload = [
'parent_id' => $parentId,
'student_id' => (int) $flag['student_id'],
'school_year' => (string) $flag['school_year'],
'source_school_year' => $flag['source_school_year'] ?? null,
'status' => 'active',
'reason_code' => (string) $flag['flag_type'],
'reason_note' => $reason,
'bypassed_rule_codes_json' => json_encode($ruleCodes, JSON_UNESCAPED_SLASHES),
'family_student_ids_json' => json_encode([(int) $flag['student_id']], JSON_UNESCAPED_SLASHES),
'created_by' => $this->userId(),
'approved_by' => $this->userId(),
'starts_at' => $now,
'expires_at' => $expiresAt,
'updated_at' => $now,
];
if ($existing !== null) {
$this->db->table('enrollment_exceptions')->where('id', (int) $existing['id'])->update($payload);
} else {
$payload['created_at'] = $now;
$this->db->table('enrollment_exceptions')->insert($payload);
}
}
$this->db->table('enrollments')
->where('student_id', (int) $flag['student_id'])
->where('school_year', (string) $flag['school_year'])
->update([
'exception_required' => 0,
'exception_reason' => $reason,
'updated_at' => date('Y-m-d H:i:s'),
'updated_at' => $now,
]);
$this->resolveFlagRow($flag, $reason, 'enrollment_exception_approved');
$this->db->transComplete();
if ($this->db->transStatus() === false) {
return redirect()->back()->with('error', 'Unable to approve enrollment exception.');
}
return redirect()->back()->with('success', 'Enrollment exception approved.');
} catch (Throwable $e) {
return redirect()->back()->with('error', $e->getMessage());
}
}
public function createException()
{
try {
if (! $this->canManageEnrollmentExceptions()) {
return redirect()->back()->with('error', 'You do not have permission to manage enrollment exceptions.');
}
if (! $this->db->tableExists('enrollment_exceptions')) {
return redirect()->back()->with('error', 'Enrollment exception storage is not available. Run migrations first.');
}
$parentId = (int) ($this->request->getPost('parent_id') ?? 0);
$studentIds = $this->request->getPost('student_ids') ?? [];
if (! is_array($studentIds)) {
$studentIds = [$this->request->getPost('student_id') ?? 0];
}
$studentIds = array_values(array_unique(array_filter(array_map('intval', $studentIds), static fn (int $id): bool => $id > 0)));
$schoolYear = trim((string) ($this->request->getPost('school_year') ?? ''));
$reasonCode = trim((string) ($this->request->getPost('reason_code') ?? ''));
$reasonNote = trim((string) ($this->request->getPost('reason_note') ?? ''));
$expiresAt = trim((string) ($this->request->getPost('expires_at') ?? ''));
$postedCodesByStudent = $this->request->getPost('bypassed_rule_codes_by_student') ?? [];
$postedCodesByStudent = is_array($postedCodesByStudent) ? $postedCodesByStudent : [];
if ($parentId <= 0 || $studentIds === [] || $schoolYear === '' || $reasonCode === '' || $reasonNote === '') {
return redirect()->back()->withInput()->with('error', 'Parent, at least one student, school year, reason code, and note are required.');
}
$sourceSchoolYear = $this->previousSchoolYearName($schoolYear);
if ($sourceSchoolYear === null) {
return redirect()->back()->withInput()->with('error', 'Unable to determine the source school year.');
}
if ($expiresAt === '') {
$expiresAt = date('Y-m-d H:i:s', strtotime('+30 days'));
} else {
try {
$expiresAt = (new \DateTimeImmutable($expiresAt))->format('Y-m-d H:i:s');
} catch (\Throwable) {
return redirect()->back()->withInput()->with('error', 'Expiration date is invalid.');
}
}
$now = date('Y-m-d H:i:s');
$transitionService = service('enrollmentTransition');
$saved = 0;
$this->db->transStart();
foreach ($studentIds as $studentId) {
if (! $this->studentLinkedToParent($studentId, $parentId)) {
$this->db->transRollback();
return redirect()->back()->withInput()->with('error', 'Selected student is not linked to the selected parent.');
}
$evaluation = $transitionService->evaluateForParent($parentId, $studentId, $sourceSchoolYear, $schoolYear, 'admin');
$failedCodes = array_values(array_unique(array_filter(array_map('strval', array_merge(
$evaluation['blocking_rule_codes'] ?? [],
$evaluation['review_rule_codes'] ?? []
)))));
$postedCodes = $postedCodesByStudent[(string) $studentId] ?? $postedCodesByStudent[$studentId] ?? [];
$postedCodes = is_array($postedCodes) ? array_values(array_filter(array_map('strval', $postedCodes))) : [];
$postedCodes = array_values(array_unique(array_map(static fn (string $code): string => strtoupper(trim($code)), $postedCodes)));
$failedCodes = array_values(array_unique(array_map(static fn (string $code): string => strtoupper(trim($code)), $failedCodes)));
$ruleCodes = $postedCodes !== [] ? array_values(array_intersect($postedCodes, $failedCodes)) : $failedCodes;
$ruleCodes = array_values(array_filter($ruleCodes, static fn (string $code): bool => $code !== ''));
$nonOverridable = array_values(array_intersect($ruleCodes, ['STUDENT_NOT_LINKED', 'SOURCE_YEAR_NOT_FOUND', 'TARGET_YEAR_NOT_FOUND', 'ALREADY_ENROLLED']));
if ($nonOverridable !== []) {
$this->db->transRollback();
return redirect()->back()->withInput()->with('error', 'These rule(s) cannot be bypassed: ' . implode(', ', $nonOverridable));
}
if ($ruleCodes === []) {
$this->db->transRollback();
return redirect()->back()->withInput()->with('error', 'No failed eligibility rule codes were selected for one or more selected students.');
}
$existing = $this->db->table('enrollment_exceptions')
->where('parent_id', $parentId)
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('status', 'active')
->limit(1)
->get()
->getRowArray();
$payload = [
'parent_id' => $parentId,
'student_id' => $studentId,
'school_year' => $schoolYear,
'source_school_year' => $sourceSchoolYear,
'status' => 'active',
'reason_code' => $reasonCode,
'reason_note' => $reasonNote,
'bypassed_rule_codes_json' => json_encode($ruleCodes, JSON_UNESCAPED_SLASHES),
'family_student_ids_json' => json_encode([$studentId], JSON_UNESCAPED_SLASHES),
'created_by' => $this->userId(),
'approved_by' => $this->userId(),
'starts_at' => $now,
'expires_at' => $expiresAt,
'updated_at' => $now,
];
if ($existing !== null) {
$this->db->table('enrollment_exceptions')->where('id', (int) $existing['id'])->update($payload);
$exceptionId = (int) $existing['id'];
} else {
$payload['created_at'] = $now;
$this->db->table('enrollment_exceptions')->insert($payload);
$exceptionId = (int) $this->db->insertID();
}
$this->audit($studentId, $schoolYear, $sourceSchoolYear, 'enrollment_exception_created', $existing, array_merge($payload, [
'id' => $exceptionId,
'evaluation_decision' => $evaluation['decision'] ?? null,
]), $reasonNote);
$this->resolveCoveredFlags($studentId, $schoolYear, $ruleCodes, $reasonNote);
$saved++;
}
$this->db->transComplete();
if ($this->db->transStatus() === false) {
return redirect()->back()->withInput()->with('error', 'Unable to save enrollment exception.');
}
return redirect()->to(site_url('administrator/enrollment-admin?school_year=' . rawurlencode($schoolYear) . '&exception_parent_id=' . $parentId))
->with('success', $saved . ' enrollment exception' . ($saved === 1 ? '' : 's') . ' saved.');
} catch (Throwable $e) {
return redirect()->back()->withInput()->with('error', $e->getMessage());
}
}
public function revokeException(int $id)
{
if (! $this->canManageEnrollmentExceptions()) {
return redirect()->back()->with('error', 'You do not have permission to manage enrollment exceptions.');
}
if ($id <= 0 || ! $this->db->tableExists('enrollment_exceptions')) {
return redirect()->back()->with('error', 'Enrollment exception was not found.');
}
$reason = trim((string) ($this->request->getPost('revocation_reason') ?? ''));
if ($reason === '') {
return redirect()->back()->with('error', 'Revocation reason is required.');
}
$exception = $this->db->table('enrollment_exceptions')->where('id', $id)->limit(1)->get()->getRowArray();
if ($exception === null || (string) ($exception['status'] ?? '') !== 'active') {
return redirect()->back()->with('error', 'Only active exceptions can be revoked.');
}
$this->db->table('enrollment_exceptions')->where('id', $id)->update([
'status' => 'revoked',
'revoked_at' => date('Y-m-d H:i:s'),
'revoked_by' => $this->userId(),
'revocation_reason' => $reason,
'updated_at' => date('Y-m-d H:i:s'),
]);
$this->audit(
(int) $exception['student_id'],
(string) $exception['school_year'],
(string) ($exception['source_school_year'] ?? ''),
'enrollment_exception_revoked',
$exception,
['status' => 'revoked', 'revocation_reason' => $reason],
$reason
);
return redirect()->back()->with('success', 'Enrollment exception revoked.');
}
private function enrollmentFlags(string $schoolYear, string $status, string $flagType, string $assignedTo): array
{
if (! $this->db->tableExists('enrollment_flags')) {
@@ -232,10 +475,12 @@ class EnrollmentAdminController extends BaseController
$builder = $this->db->table('enrollment_flags ef')
->select('ef.*')
->select('s.firstname, s.lastname, s.school_id')
->select('s.firstname, s.lastname, s.school_id, s.parent_id')
->select('u.firstname AS assignee_firstname, u.lastname AS assignee_lastname')
->select('p.firstname AS parent_firstname, p.lastname AS parent_lastname')
->join('students s', 's.id = ef.student_id', 'left')
->join('users u', 'u.id = ef.assigned_to', 'left')
->join('users p', 'p.id = s.parent_id', 'left')
->orderBy('ef.created_at', 'DESC')
->orderBy('ef.id', 'DESC');
@@ -256,6 +501,7 @@ class EnrollmentAdminController extends BaseController
foreach ($rows as &$row) {
$row['student_name'] = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')) ?: 'Student #' . (int) ($row['student_id'] ?? 0);
$row['assignee_name'] = trim((string) ($row['assignee_firstname'] ?? '') . ' ' . (string) ($row['assignee_lastname'] ?? ''));
$row['parent_name'] = trim((string) ($row['parent_firstname'] ?? '') . ' ' . (string) ($row['parent_lastname'] ?? '')) ?: ((int) ($row['parent_id'] ?? 0) > 0 ? 'Parent #' . (int) $row['parent_id'] : '');
$row['details'] = json_decode((string) ($row['details_json'] ?? ''), true) ?: [];
}
unset($row);
@@ -263,10 +509,10 @@ class EnrollmentAdminController extends BaseController
return $rows;
}
private function enrollmentFollowups(string $schoolYear): array
private function enrollmentFollowups(string $schoolYear, array $flags = []): array
{
if (! $this->db->tableExists('enrollments')) {
return [];
return $this->followupsFromFlags($flags, []);
}
$fields = $this->db->getFieldNames('enrollments');
@@ -338,10 +584,16 @@ class EnrollmentAdminController extends BaseController
if (in_array('deliberation_decision', $fields, true)) {
$builder->orWhereIn('e.deliberation_decision', [
'make_up_exam',
'MAKE_UP_EXAM',
'repeat_class',
'REPEAT_CLASS',
'deferred',
'DEFERRED',
'DEFERRED_DECISION',
'expelled',
'EXPELLED',
'withdrawn',
'WITHDRAWN',
]);
}
@@ -351,9 +603,76 @@ class EnrollmentAdminController extends BaseController
}
unset($row);
return $this->followupsFromFlags($flags, $rows);
}
private function followupsFromFlags(array $flags, array $rows): array
{
$seen = [];
foreach ($rows as $row) {
$seen[(int) ($row['student_id'] ?? 0)] = true;
}
$followupTypes = [
'PENDING_MAKE_UP_EXAM_PROMOTION' => 'temporary_same_grade',
'CLASS_REASSIGNMENT_REQUIRED' => 'manual_class_required',
'CLASS_CAPACITY_EXCEPTION_REQUIRED' => 'manual_class_required',
'COMPLETION_OR_EXIT_PROCESS_REQUIRED' => 'exit_required',
];
foreach ($flags as $flag) {
$type = (string) ($flag['flag_type'] ?? '');
$studentId = (int) ($flag['student_id'] ?? 0);
if ($studentId <= 0 || isset($seen[$studentId]) || ! isset($followupTypes[$type])) {
continue;
}
$seen[$studentId] = true;
$rows[] = [
'id' => 0,
'student_id' => $studentId,
'student_name' => (string) ($flag['student_name'] ?? ''),
'school_id' => (string) ($flag['school_id'] ?? ''),
'enrollment_status' => 'not enrolled',
'deliberation_decision' => $type === 'PENDING_MAKE_UP_EXAM_PROMOTION' ? 'MAKE_UP_EXAM' : '',
'placement_status' => $followupTypes[$type],
'class_section_name' => '',
'exception_required' => 0,
'exception_reason' => $type,
'updated_at' => $flag['created_at'] ?? null,
];
}
return $rows;
}
private function exceptionNeededFromFlags(array $flags): array
{
$exceptionTypes = [
'AGE_EXCEPTION_REQUIRED',
'LATE_REGISTRATION_EXCEPTION',
'FINANCIAL_REVIEW_REQUIRED',
'CLASS_CAPACITY_EXCEPTION_REQUIRED',
'SIBLING_LAST_NAME_MISMATCH',
'DEFERRED_DELIBERATION',
'WITHDRAWAL_REVIEW_REQUIRED',
'RESTRICTED_ADMINISTRATIVE_REVIEW',
];
$needed = [];
foreach ($flags as $flag) {
if (($flag['status'] ?? 'open') !== 'open') {
continue;
}
if (! in_array((string) ($flag['flag_type'] ?? ''), $exceptionTypes, true)) {
continue;
}
$needed[] = $flag;
}
return $needed;
}
private function requireFlag(int $id): array
{
if ($id <= 0 || ! $this->db->tableExists('enrollment_flags')) {
@@ -392,6 +711,29 @@ class EnrollmentAdminController extends BaseController
}
}
private function resolveCoveredFlags(int $studentId, string $schoolYear, array $ruleCodes, string $notes): void
{
if ($studentId <= 0 || $schoolYear === '' || $ruleCodes === [] || ! $this->db->tableExists('enrollment_flags')) {
return;
}
$ruleCodes = array_values(array_unique(array_map(static fn (string $code): string => strtoupper(trim($code)), $ruleCodes)));
$flags = $this->db->table('enrollment_flags')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('status', 'open')
->get()
->getResultArray();
foreach ($flags as $flag) {
$flagType = strtoupper(trim((string) ($flag['flag_type'] ?? '')));
$mapped = array_map('strtoupper', $this->ruleCodesForFlag($flagType));
if (in_array($flagType, $ruleCodes, true) || array_intersect($mapped, $ruleCodes) !== []) {
$this->resolveFlagRow($flag, $notes, 'enrollment_exception_created');
}
}
}
private function applyClassSection(int $studentId, string $schoolYear, int $sectionId, string $auditAction, string $placementStatus = 'manual_class_assigned'): void
{
$section = $this->db->table('classSection')
@@ -514,6 +856,270 @@ class EnrollmentAdminController extends BaseController
return $rows;
}
private function enrollmentExceptions(string $schoolYear): array
{
if (! $this->db->tableExists('enrollment_exceptions')) {
return [];
}
$builder = $this->db->table('enrollment_exceptions ee')
->select('ee.*')
->select('s.firstname, s.lastname, s.school_id')
->select('u.firstname AS parent_firstname, u.lastname AS parent_lastname')
->select('admin.firstname AS admin_firstname, admin.lastname AS admin_lastname')
->join('students s', 's.id = ee.student_id', 'left')
->join('users u', 'u.id = ee.parent_id', 'left')
->join('users admin', 'admin.id = ee.created_by', 'left')
->orderBy('ee.created_at', 'DESC')
->orderBy('ee.id', 'DESC')
->limit(100);
if ($schoolYear !== '') {
$builder->where('ee.school_year', $schoolYear);
}
$rows = $builder->get()->getResultArray();
foreach ($rows as &$row) {
$row['student_name'] = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')) ?: 'Student #' . (int) ($row['student_id'] ?? 0);
$row['parent_name'] = trim((string) ($row['parent_firstname'] ?? '') . ' ' . (string) ($row['parent_lastname'] ?? '')) ?: 'Parent #' . (int) ($row['parent_id'] ?? 0);
$row['created_by_name'] = trim((string) ($row['admin_firstname'] ?? '') . ' ' . (string) ($row['admin_lastname'] ?? '')) ?: ((int) ($row['created_by'] ?? 0) > 0 ? 'User #' . (int) $row['created_by'] : '');
$decoded = json_decode((string) ($row['bypassed_rule_codes_json'] ?? ''), true);
$row['bypassed_rule_codes'] = is_array($decoded) ? array_values(array_map('strval', $decoded)) : [];
}
unset($row);
return $rows;
}
private function exceptionFamilies(): array
{
if (! $this->db->tableExists('students') || ! $this->db->tableExists('users')) {
return [];
}
$rows = $this->db->table('students s')
->select('s.id AS student_id, s.firstname AS student_firstname, s.lastname AS student_lastname, s.school_id, s.parent_id')
->select('u.firstname AS parent_firstname, u.lastname AS parent_lastname, u.email AS parent_email')
->join('users u', 'u.id = s.parent_id', 'left')
->where('s.parent_id >', 0)
->orderBy('u.lastname', 'ASC')
->orderBy('u.firstname', 'ASC')
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->limit(1000)
->get()
->getResultArray();
$families = [];
foreach ($rows as $row) {
$parentId = (int) ($row['parent_id'] ?? 0);
if ($parentId <= 0) {
continue;
}
if (! isset($families[$parentId])) {
$parentName = trim((string) ($row['parent_firstname'] ?? '') . ' ' . (string) ($row['parent_lastname'] ?? ''));
$families[$parentId] = [
'parent_id' => $parentId,
'parent_name' => $parentName !== '' ? $parentName : 'Parent #' . $parentId,
'parent_email' => (string) ($row['parent_email'] ?? ''),
'students' => [],
];
}
$studentId = (int) ($row['student_id'] ?? 0);
$studentName = trim((string) ($row['student_firstname'] ?? '') . ' ' . (string) ($row['student_lastname'] ?? ''));
$families[$parentId]['students'][] = [
'student_id' => $studentId,
'student_name' => $studentName !== '' ? $studentName : 'Student #' . $studentId,
'school_id' => (string) ($row['school_id'] ?? ''),
];
}
$families = array_values($families);
usort($families, static function (array $left, array $right): int {
return strnatcasecmp(
(string) ($left['parent_name'] ?? ''),
(string) ($right['parent_name'] ?? '')
);
});
return $families;
}
private function exceptionReasonCodes(): array
{
return [
'ADMIN_REVIEW_APPROVED' => 'Administrative review approved',
'FINANCE_APPROVAL_REQUIRED' => 'Finance approval',
'SIBLING_LAST_NAME_REVIEWED' => 'Sibling last name reviewed',
'AGE_EXCEPTION_APPROVED' => 'Age exception approved',
'LATE_REGISTRATION_APPROVED' => 'Late registration approved',
'ACADEMIC_STATUS_EXCEPTION' => 'Academic status exception',
'MANUAL_PLACEMENT_EXCEPTION' => 'Manual placement exception',
];
}
private function exceptionPreview(int $parentId, string $schoolYear): ?array
{
if ($parentId <= 0 || $schoolYear === '') {
return null;
}
$sourceSchoolYear = $this->previousSchoolYearName($schoolYear);
if ($sourceSchoolYear === null) {
return [
'error' => 'Unable to determine the previous school year for ' . $schoolYear . '.',
];
}
$parent = $this->db->table('users u')
->select('u.firstname, u.lastname, u.email')
->where('u.id', $parentId)
->limit(1)
->get()
->getRowArray() ?: [];
$studentRows = $this->db->table('students s')
->select('s.id, s.firstname, s.lastname, s.school_id')
->where('s.parent_id', $parentId)
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->get()
->getResultArray();
$students = [];
foreach ($studentRows as $student) {
$linkedStudentId = (int) ($student['id'] ?? 0);
if ($linkedStudentId <= 0) {
continue;
}
$students[] = [
'student_id' => $linkedStudentId,
'student_name' => trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')) ?: 'Student #' . $linkedStudentId,
'school_id' => (string) ($student['school_id'] ?? ''),
'evaluation' => service('enrollmentTransition')->evaluateForParent($parentId, $linkedStudentId, $sourceSchoolYear, $schoolYear, 'admin'),
];
}
return [
'parent_id' => $parentId,
'school_year' => $schoolYear,
'source_school_year' => $sourceSchoolYear,
'parent_name' => trim((string) ($parent['firstname'] ?? '') . ' ' . (string) ($parent['lastname'] ?? '')) ?: 'Parent #' . $parentId,
'parent_email' => (string) ($parent['email'] ?? ''),
'students' => $students,
];
}
private function studentLinkedToParent(int $studentId, int $parentId): bool
{
if ($studentId <= 0 || $parentId <= 0 || ! $this->db->tableExists('students')) {
return false;
}
return $this->db->table('students')
->where('id', $studentId)
->where('parent_id', $parentId)
->countAllResults() > 0;
}
private function previousSchoolYearName(string $schoolYear): ?string
{
$schoolYear = trim($schoolYear);
if (! preg_match('/^(\d{4})-(\d{4})$/', $schoolYear, $matches)) {
return null;
}
return ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1);
}
private function parentIdForStudent(int $studentId, string $schoolYear): int
{
if ($studentId <= 0) {
return 0;
}
if ($schoolYear !== '' && $this->db->tableExists('enrollments')) {
$row = $this->db->table('enrollments')
->select('parent_id')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->orderBy('updated_at', 'DESC')
->orderBy('id', 'DESC')
->limit(1)
->get()
->getRowArray();
if ((int) ($row['parent_id'] ?? 0) > 0) {
return (int) $row['parent_id'];
}
}
$student = $this->db->table('students')
->select('parent_id')
->where('id', $studentId)
->limit(1)
->get()
->getRowArray();
return (int) ($student['parent_id'] ?? 0);
}
private function ruleCodesForFlag(string $flagType): array
{
return match ($flagType) {
'AGE_EXCEPTION_REQUIRED' => ['AGE_RULE_BLOCKED'],
'LATE_REGISTRATION_EXCEPTION' => ['REGISTRATION_CLOSED'],
'FINANCIAL_REVIEW_REQUIRED' => ['OUTSTANDING_BALANCE_BLOCKED', 'FINANCE_APPROVAL_REQUIRED'],
'CLASS_CAPACITY_EXCEPTION_REQUIRED' => ['CLASS_CAPACITY_EXCEPTION_REQUIRED'],
'RESTRICTED_ADMINISTRATIVE_REVIEW' => ['EXPELLED'],
'WITHDRAWAL_REVIEW_REQUIRED' => ['WITHDRAWN'],
'DEFERRED_DELIBERATION' => ['NO_FINAL_DECISION', 'UNRECOGNIZED_DECISION', 'DEFERRED_DECISION'],
'SIBLING_LAST_NAME_MISMATCH' => ['SIBLING_LAST_NAME_MISMATCH'],
default => [$flagType],
};
}
private function canManageEnrollmentExceptions(): bool
{
$userId = $this->userId();
if ($userId <= 0) {
return false;
}
if (! $this->db->tableExists('permissions') || ! $this->db->tableExists('role_permissions') || ! $this->db->tableExists('user_roles')) {
return $this->isAdministratorSessionRole();
}
$rows = $this->db->table('user_roles ur')
->select('rp.*')
->join('role_permissions rp', 'rp.role_id = ur.role_id')
->join('permissions p', 'p.id = rp.permission_id')
->where('ur.user_id', $userId)
->where('LOWER(p.name)', 'enrollment.exception.manage')
->get()
->getResultArray();
foreach ($rows as $row) {
if (! empty($row['can_create']) || ! empty($row['can_update']) || ! empty($row['can_delete'])) {
return true;
}
}
return false;
}
private function isAdministratorSessionRole(): bool
{
$roles = array_map(static fn ($role): string => strtolower(trim((string) $role)), (array) session()->get('roles'));
$activeRole = strtolower(trim((string) session()->get('role')));
if ($activeRole !== '') {
$roles[] = $activeRole;
}
return (bool) array_intersect(array_unique($roles), ['administrator', 'principal']);
}
private function launchState(string $schoolYear): array
{
if ($schoolYear === '' || ! $this->db->tableExists('school_years')) {
@@ -579,6 +1185,20 @@ class EnrollmentAdminController extends BaseController
return is_numeric($row['parent_id'] ?? null) ? (int) $row['parent_id'] : null;
}
private function openFlagCount(string $schoolYear): int
{
if (! $this->db->tableExists('enrollment_flags')) {
return 0;
}
$builder = $this->db->table('enrollment_flags')->where('status', 'open');
if ($schoolYear !== '') {
$builder->where('school_year', $schoolYear);
}
return $builder->countAllResults();
}
private function flagTypes(): array
{
if (! $this->db->tableExists('enrollment_flags')) {
+34 -1
View File
@@ -152,6 +152,10 @@ class FilesController extends Controller
throw PageNotFoundException::forPageNotFound();
}
if (! $this->canViewEarlyDismissalSignature($name)) {
return $this->response->setStatusCode(403, 'You are not allowed to access this file.');
}
// 3) Build path under writable (EARLY DISMISSAL SIGNATURES)
$path = WRITEPATH . 'uploads/early_dismissal_signatures/' . $name;
if (!is_file($path)) {
@@ -200,7 +204,7 @@ class FilesController extends Controller
->setHeader('Content-Length', (string) $size)
->setHeader('ETag', $etag)
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT')
->setHeader('Cache-Control', 'public, max-age=86400')
->setHeader('Cache-Control', 'private, no-store')
->setBody(file_get_contents($path));
}
@@ -433,6 +437,35 @@ class FilesController extends Controller
return $draftSemester === '' || $currentSemester === '' || $draftSemester === $currentSemester;
}
private function canViewEarlyDismissalSignature(string $name): bool
{
$userId = (int) (session()->get('user_id') ?? 0);
if ($userId <= 0) {
return false;
}
$roles = array_map('strtolower', (array) (session()->get('roles') ?? []));
$activeRole = strtolower((string) (session()->get('role') ?? ''));
if ($activeRole !== '' && ! in_array($activeRole, $roles, true)) {
$roles[] = $activeRole;
}
foreach (['administrator', 'administrative staff', 'principal', 'admin', 'teacher', 'teacher_assistant'] as $role) {
if (in_array($role, $roles, true)) {
return true;
}
}
$row = \Config\Database::connect()
->table('early_dismissal_signatures')
->select('uploaded_by')
->where('filename', $name)
->get()
->getRowArray();
return $row !== null && (int) ($row['uploaded_by'] ?? 0) === $userId;
}
private function expenseRecordForFile(string $name): ?array
{
return \Config\Database::connect()
@@ -10,7 +10,6 @@ class HealthController extends Controller
{
return [
'label' => $label,
'path' => $path,
'exists' => is_dir($path),
'writable' => is_writable($path),
];
@@ -65,7 +64,6 @@ class HealthController extends Controller
'ok' => $ok,
'paths' => $pathsStatus,
'database' => $dbChecks,
'write_path' => WRITEPATH,
'timestamp' => date('c'),
];
+34 -52
View File
@@ -22,6 +22,7 @@ use App\Libraries\FinancialStatus;
use App\Libraries\IssueInvoiceCommand;
use App\Libraries\InvoiceIssuanceService;
use App\Libraries\InvoiceLedgerService;
use App\Libraries\Tuition\GradeLevelParser;
use DateTime;
use DateTimeZone;
@@ -44,7 +45,6 @@ class InvoiceController extends ResourceController
protected $studentClassModel;
protected $firstStudentFee;
protected $secondStudentFee;
protected $youthFee;
protected $refundDeadline;
protected $invoiceEventModel;
protected $paymentModel;
@@ -83,9 +83,8 @@ class InvoiceController extends ResourceController
$this->semester = $this->configModel->getConfig('semester');
$this->dueDate = $this->configModel->getConfig('first_day_of_school')
?: $this->configModel->getConfig('due_date');
$this->firstStudentFee = (float) ($this->configModel->getConfig('first_student_fee') ?? 350);
$this->secondStudentFee = (float) ($this->configModel->getConfig('second_student_fee') ?? 200);
$this->youthFee = (float) ($this->configModel->getConfig('youth_fee') ?? 200);
$this->firstStudentFee = (float) ($this->configModel->getConfig('first_student_fee') ?? 380);
$this->secondStudentFee = (float) ($this->configModel->getConfig('second_student_fee') ?? 280);
$this->refundDeadline = date('Y-m-d', strtotime($this->configModel->getConfig('refund_deadline')));
}
@@ -676,36 +675,16 @@ class InvoiceController extends ResourceController
}
unset($student); // break reference
// 2) Partition into regular (<= grade 9) vs youth (> grade 9)
$regularCount = 0;
$youthCount = 0;
// 2) First student pays the base fee; every additional student pays base minus $100.
usort($students, fn (array $left, array $right): int => GradeLevelParser::parse($left['grade'] ?? null) <=> GradeLevelParser::parse($right['grade'] ?? null));
foreach ($students as $student) {
$levelInfo = $this->getGradeLevel($student['grade']);
$level = (int) ($levelInfo['level'] ?? 999);
// Youth if level > $this->gradeFee (e.g., gradeFee = 9)
if ($level > $this->gradeFee) {
$youthCount++;
} else {
$regularCount++;
}
}
// 3) Calculate totals per your rules
$studentCount = 0;
$total = 0.0;
// Youth: flat youth fee per student
$total += $youthCount * $this->youthFee;
// Regulars: first student full price, others discounted — but only if 2+ regulars
if ($regularCount >= 2) {
$total += $this->firstStudentFee; // one full
$total += ($regularCount - 1) * $this->secondStudentFee; // rest discounted
} elseif ($regularCount === 1) {
$total += $this->firstStudentFee; // single regular: no discount even if there are youths
foreach ($students as $student) {
$total += ($studentCount === 0) ? $this->firstStudentFee : $this->secondStudentFee;
$studentCount++;
}
// if 0 regulars, nothing to add here
return $total;
}
@@ -860,41 +839,27 @@ class InvoiceController extends ResourceController
$refundAllowed = $currentDate <= $deadline;
$studentCharges = [];
$regularCount = 0;
$studentCount = 0;
/**
* Computes the fee for a student given numeric level and original grade name.
* - KG/K/Kindergarten are always treated as "regular".
* - Otherwise, "regular" means gradeLevel <= $this->gradeFee threshold.
* First student pays the base fee; every additional student pays base minus $100.
*/
$computeCharge = function (int $gradeLevel, string $gradeName) use (&$regularCount) {
$threshold = (int) $this->gradeFee;
// Force Kindergarten to be regular regardless of numeric mapping
$isRegular = $this->isKindergarten($gradeName) || ($gradeLevel > 0 && $gradeLevel <= $threshold);
if ($isRegular) {
$fee = ($regularCount === 0) ? $this->firstStudentFee : $this->secondStudentFee;
$regularCount++;
return $fee;
}
return $this->youthFee;
$computeCharge = function (array $student) use (&$studentCount) {
$fee = ($studentCount === 0) ? $this->firstStudentFee : $this->secondStudentFee;
$studentCount++;
return $fee;
};
// Registered kids -> pay unit fee
foreach ($registeredKids as $student) {
$gradeName = (string)$student['grade'];
$gradeLevel = $this->gradeLevelInt($gradeName);
$unitFee = $computeCharge($gradeLevel, $gradeName);
$unitFee = $computeCharge($student);
$studentCharges[$student['student_id']] = ['unit_fee' => $unitFee, 'refund' => 0];
}
// Refund NOT allowed -> withdrawn students still owe unit fee
if (!$refundAllowed) {
foreach ($withdrawnKids as $student) {
$gradeName = (string)$student['grade'];
$gradeLevel = $this->gradeLevelInt($gradeName);
$unitFee = $computeCharge($gradeLevel, $gradeName);
$unitFee = $computeCharge($student);
$studentCharges[$student['student_id']] = ['unit_fee' => $unitFee, 'refund' => 0];
}
}
@@ -1625,6 +1590,23 @@ private function getGradeLevel($grade): array
// View: Get invoices by parent ID (for web views)
public function getByParent($parentId)
{
$parentId = (int) $parentId;
$userId = (int) (session()->get('user_id') ?? 0);
$roles = array_map(
static fn ($role): string => strtolower(trim((string) $role)),
array_filter(array_merge((array) session()->get('roles'), [session()->get('role')]))
);
$isStaff = (bool) array_intersect($roles, [
'administrator',
'administrative staff',
'principal',
'admin',
]);
if ($userId <= 0 || (! $isStaff && $userId !== $parentId)) {
return redirect()->to('/access_denied');
}
$invoices = $this->invoiceModel->getInvoicesByUserId($parentId, $this->schoolYear);
return view('invoice_list', ['invoices' => $invoices]);
}
@@ -92,6 +92,10 @@ class MessagesController extends BaseController
$userRoleModel = new UserRoleModel();
//$role = session()->get('role');
$userId = session()->get('user_id');
if (empty($userId)) {
return redirect()->to('/login');
}
// Fetch the user role from the user_roles and roles tables
$role = $userRoleModel->select('roles.name')
->join('roles', 'roles.id = user_roles.role_id')
@@ -103,6 +107,11 @@ class MessagesController extends BaseController
$attachmentPath = null;
$file = $this->request->getFile('attachment');
if ($file && $file->isValid() && !$file->hasMoved()) {
$allowedExt = ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'webp', 'doc', 'docx'];
$ext = strtolower((string) $file->getExtension());
if (! in_array($ext, $allowedExt, true) || $file->getSize() > 5 * 1024 * 1024) {
return redirect()->back()->with('error', 'Attachment must be a document or image under 5MB.');
}
$attachmentPath = $file->store();
}
+283 -83
View File
@@ -92,15 +92,6 @@ class ParentController extends BaseController
$this->maxEmergency = (int) $this->configModel->getConfig('max_emergency') ?? 0;
helper(['url', 'form']);
if (!session()->get('is_logged_in')) {
return redirect()->to('/login');
}
// Add more role-specific checks if needed
if (session()->get('role') !== 'parent') {
return redirect()->to('/access_denied');
}
}
public function index()
@@ -252,9 +243,6 @@ class ParentController extends BaseController
public function enrollClasses()
{
try {
// Log session data for debugging
log_message('info', 'Session Data: ' . print_r(session()->get(), true));
// Get deadlines and school year from config
if (!$this->schoolYear) {
log_message('error', 'Current school year not found in configuration.');
@@ -373,7 +361,7 @@ class ParentController extends BaseController
if ($isEditable) {
$student['transition_evaluation'] = $previousSchoolYear !== null
? $this->transitionEvaluationForStudent((int) $studentId, $previousSchoolYear, $selectedYear)
? $this->transitionEvaluationForStudent((int) $parentId, (int) $studentId, $previousSchoolYear, $selectedYear)
: null;
$student['enrollment_eligibility_message'] = $this->eligibilityMessageFromTransition(
$student,
@@ -382,11 +370,13 @@ class ParentController extends BaseController
);
$student['expected_placement_label'] = $this->expectedPlacementLabel($student['transition_evaluation']);
$student['required_action_label'] = $this->requiredActionLabel($student['transition_evaluation']);
$student['parent_enrollment_state'] = $this->parentEnrollmentState($student);
} else {
$student['transition_evaluation'] = $this->readonlyEnrollmentEvaluation($student['previous_year_decision']);
$student['enrollment_eligibility_message'] = ['message' => '', 'blocking' => false, 'level' => 'info'];
$student['expected_placement_label'] = (string) ($student['class_section'] ?? 'Class not Assigned');
$student['required_action_label'] = 'Read-only closed school year.';
$student['parent_enrollment_state'] = $this->parentEnrollmentState($student);
}
}
@@ -416,8 +406,6 @@ class ParentController extends BaseController
public function enrollClassesHandler()
{
// Call enrollClasses() function at the start of this method
$this->enrollClasses();
$refundService = new FeeCalculationService();
// Retrieve enrollment and withdrawal data from the POST request
@@ -460,25 +448,70 @@ class ParentController extends BaseController
if (!empty($enroll)) {
$selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
$blockingDecisionMessages = $this->blockedEnrollmentDecisionMessages(array_map('intval', (array) $enroll), $selectedYear);
if ($blockingDecisionMessages !== []) {
return redirect()->back()->withInput()->with('error', implode(' ', $blockingDecisionMessages));
$previousSchoolYear = $this->previousSchoolYearName($selectedYear);
if ($previousSchoolYear === null) {
return redirect()->back()->withInput()->with('error', 'Enrollment cannot be submitted because the closing school year could not be determined.');
}
$financialBlockers = $this->financialSubmissionBlockers((int) $parentId, $selectedYear);
if ($financialBlockers !== []) {
return redirect()->back()->withInput()->with('error', implode(' ', $financialBlockers));
$parent = $this->userModel->find((int) $parentId);
if (! is_array($parent) || ($parent['user_type'] ?? '') !== 'primary') {
return redirect()->back()->withInput()->with('error', 'Only primary parents can enroll students.');
}
$transitionService = service('enrollmentTransition');
$submittedStudentIds = array_values(array_unique(array_filter(array_map('intval', (array) $enroll), static fn (int $id): bool => $id > 0)));
$evaluations = [];
$errors = [];
foreach ($submittedStudentIds as $studentId) {
try {
$evaluation = $transitionService->evaluateForParent((int) $parentId, $studentId, $previousSchoolYear, $selectedYear, 'parent');
} catch (Throwable $e) {
log_message('error', 'Parent enrollment eligibility evaluation failed for student {studentId}: {message}', [
'studentId' => $studentId,
'message' => $e->getMessage(),
]);
$errors[] = 'Student ID ' . $studentId . ': enrollment eligibility could not be evaluated. Please contact administration.';
continue;
}
$studentInfo = $this->studentModel->find($studentId);
if (! is_array($studentInfo)) {
$errors[] = 'Student ID ' . $studentId . ': student record was not found.';
continue;
}
$studentName = trim((string) ($studentInfo['firstname'] ?? '') . ' ' . (string) ($studentInfo['lastname'] ?? '')) ?: 'Student ID ' . $studentId;
if (empty($evaluation['can_enroll'])) {
$messages = array_values(array_filter(array_map('trim', array_map('strval', $evaluation['blockers'] ?? []))));
$codes = array_values(array_filter(array_map('strval', array_merge($evaluation['blocking_rule_codes'] ?? [], $evaluation['review_rule_codes'] ?? []))));
$errors[] = $studentName . ': ' . ($messages !== [] ? implode(' ', $messages) : 'Enrollment is not currently allowed' . ($codes !== [] ? ' (' . implode(', ', $codes) . ')' : '') . '.');
continue;
}
if (! $this->studentModel->getStudentSchoolIdByStudentId($studentId)) {
$errors[] = $studentName . ': Student school ID not found.';
continue;
}
$evaluations[$studentId] = $evaluation;
}
if ($errors !== []) {
return redirect()->back()->withInput()->with('error', implode(' ', $errors));
}
$this->db->transStart();
foreach ($enroll as $studentId) {
$studentId = (int) $studentId;
if (! isset($evaluations[$studentId])) {
continue;
}
$evaluation = $evaluations[$studentId];
// Get student full name (supports both string return or array with firstname/lastname)
$studentInfo = $this->studentModel->getFullNameById($studentId);
if (empty($studentName)) {
$studentName = "Student ID $studentId";
log_message('warning', "Name for student ID $studentId not found in students table.");
}
// Save student info into $studentData
$studentData[$studentId] = $studentInfo; // raw return from getFullName()
@@ -490,49 +523,57 @@ class ParentController extends BaseController
->get()
->getRowArray();
$studentSchoolId = $this->studentModel->getStudentSchoolIdByStudentId($studentId);
if (!$studentSchoolId) {
return redirect()->back()->with('error', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']}: Student school ID not found.");
}
$studentName = trim((string) ($studentData[$studentId]['firstname'] ?? '') . ' ' . (string) ($studentData[$studentId]['lastname'] ?? '')) ?: 'Student ID ' . $studentId;
if ($existingEnrollment) {
$isReturningReEnrollment = $this->isReturningReEnrollmentStudent((int)$studentId, $selectedYear);
$targetEnrollmentStatus = $isReturningReEnrollment ? 'payment pending' : 'admission under review';
$targetAdmissionStatus = $isReturningReEnrollment ? 'accepted' : 'pending';
$update = $this->enrollmentPayloadFromEvaluation($evaluation, [
'is_withdrawn' => 0,
'withdrawal_date' => null,
'enrollment_status' => $targetEnrollmentStatus,
'admission_status' => $targetAdmissionStatus,
'updated_at' => utc_now(),
]);
if ($existingEnrollment['is_withdrawn'] == 1) {
// Reactivate the enrollment if the student was previously withdrawn
$this->enrollmentModel->where('id', $existingEnrollment['id'])->update([
'is_withdrawn' => 0,
'withdrawal_date' => null,
'enrollment_status' => $targetEnrollmentStatus,
'admission_status' => $targetAdmissionStatus,
'updated_at' => utc_now()
]);
$this->enrollmentModel->where('id', $existingEnrollment['id'])->update($update);
log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) has been re-enrolled in enrollment ID {$existingEnrollment['id']}.");
// Apply promotion-based class placement for the upcoming year
$this->applyPromotionAssignment((int)$studentId, $selectedYear, $isReturningReEnrollment);
} else {
$currentStatus = (string) ($existingEnrollment['enrollment_status'] ?? '');
$update = [
'updated_at' => utc_now(),
];
if ($currentStatus === 'enrolled') {
$update['admission_status'] = 'accepted';
} else {
$update['enrollment_status'] = $targetEnrollmentStatus;
$update['admission_status'] = $targetAdmissionStatus;
}
$this->enrollmentModel->where('id', $existingEnrollment['id'])->update($update);
log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) is already actively enrolled.");
$this->applyPromotionAssignment((int)$studentId, $selectedYear, $isReturningReEnrollment);
}
$enrollmentId = (int) $existingEnrollment['id'];
if (! empty($evaluation['admin_exception']['id'])) {
$transitionService->markExceptionUsed((int) $evaluation['admin_exception']['id'], $enrollmentId);
}
$transitionService->auditEnrollmentDecision(
$studentId,
$selectedYear,
$previousSchoolYear,
! empty($evaluation['admin_exception']) ? 'parent_enrollment_submitted_with_exception' : 'parent_enrollment_submitted',
(int) $parentId,
$existingEnrollment,
$this->enrollmentAuditPayload($update, $evaluation),
implode(' ', array_map('strval', $evaluation['rule_codes'] ?? []))
);
} else {
$isReturningReEnrollment = $this->isReturningReEnrollmentStudent((int)$studentId, $selectedYear);
$targetEnrollmentStatus = $isReturningReEnrollment ? 'payment pending' : 'admission under review';
$targetAdmissionStatus = $isReturningReEnrollment ? 'accepted' : 'pending';
// If no enrollment record exists, insert a new enrollment record
$result = $this->enrollmentModel->insert([
$payload = $this->enrollmentPayloadFromEvaluation($evaluation, [
'student_id' => $studentId,
'parent_id' => $parentId,
'school_year' => $selectedYear,
@@ -543,16 +584,38 @@ class ParentController extends BaseController
'admission_status' => $targetAdmissionStatus,
'created_at' => utc_now()
]);
$result = $this->enrollmentModel->insert($payload, true);
if (!$result) {
dd($this->enrollmentModel->errors());
$this->db->transRollback();
return redirect()->back()->withInput()->with('error', $studentName . ': Unable to save enrollment.');
} else {
log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) has been newly enrolled.");
// Apply promotion-based class placement for the upcoming year
$this->applyPromotionAssignment((int)$studentId, $selectedYear, $isReturningReEnrollment);
}
$enrollmentId = (int) $result;
if (! empty($evaluation['admin_exception']['id'])) {
$transitionService->markExceptionUsed((int) $evaluation['admin_exception']['id'], $enrollmentId);
}
$transitionService->auditEnrollmentDecision(
$studentId,
$selectedYear,
$previousSchoolYear,
! empty($evaluation['admin_exception']) ? 'parent_enrollment_submitted_with_exception' : 'parent_enrollment_submitted',
(int) $parentId,
null,
$this->enrollmentAuditPayload($payload, $evaluation),
implode(' ', array_map('strval', $evaluation['rule_codes'] ?? []))
);
}
}
$this->db->transComplete();
if (! $this->db->transStatus()) {
return redirect()->back()->withInput()->with('error', 'A database error occurred while submitting enrollment.');
}
}
// $studentData now holds info for all students processed
@@ -652,6 +715,74 @@ class ParentController extends BaseController
}
}
private function enrollmentPayloadFromEvaluation(array $evaluation, array $base): array
{
$payload = array_merge($base, [
'source_school_year' => $evaluation['source_school_year'] ?? null,
'deliberation_decision' => $evaluation['deliberation_decision'] ?? null,
'source_grade_id' => $evaluation['source_grade_id'] ?? null,
'assigned_grade_id' => $evaluation['assigned_grade_id'] ?? null,
'source_class_section_id' => $evaluation['source_class_section_id'] ?? null,
'assigned_class_section_id' => $evaluation['assigned_class_section_id'] ?? null,
'class_section_id' => $evaluation['assigned_class_section_id'] ?? ($base['class_section_id'] ?? null),
'placement_status' => $evaluation['placement_status'] ?? null,
'age_reference_date' => $evaluation['age_reference_date'] ?? null,
'age_on_reference_date' => $evaluation['age_on_reference_date'] ?? null,
'adult_student' => ! empty($evaluation['adult_student']) ? 1 : 0,
'parent_enrollment_allowed' => ! empty($evaluation['parent_enrollment_allowed']) ? 1 : 0,
'student_self_enrollment_allowed' => ! empty($evaluation['student_self_enrollment_allowed']) ? 1 : 0,
'exception_required' => ! empty($evaluation['admin_exception']) || ! empty($evaluation['flags']) ? 1 : 0,
'exception_reason' => $this->exceptionReasonFromEvaluation($evaluation),
'registration_submitted_at' => utc_now(),
]);
return $this->filterEnrollmentPayloadByColumns($payload);
}
private function exceptionReasonFromEvaluation(array $evaluation): ?string
{
if (! empty($evaluation['admin_exception'])) {
return 'Admin exception: ' . (string) ($evaluation['admin_exception']['reason_code'] ?? 'approved');
}
$codes = array_values(array_filter(array_map('strval', array_merge(
$evaluation['blocking_rule_codes'] ?? [],
$evaluation['review_rule_codes'] ?? [],
$evaluation['warning_rule_codes'] ?? []
))));
return $codes !== [] ? implode(', ', array_unique($codes)) : null;
}
private function enrollmentAuditPayload(array $payload, array $evaluation): array
{
return [
'enrollment' => $payload,
'eligibility' => [
'decision' => $evaluation['decision'] ?? null,
'can_enroll' => ! empty($evaluation['can_enroll']),
'rule_codes' => $evaluation['rule_codes'] ?? [],
'blocking_rule_codes' => $evaluation['blocking_rule_codes'] ?? [],
'review_rule_codes' => $evaluation['review_rule_codes'] ?? [],
'warning_rule_codes' => $evaluation['warning_rule_codes'] ?? [],
'admin_exception' => $evaluation['admin_exception'] ?? null,
'financial_summary' => $evaluation['financial_summary'] ?? null,
'last_name_exception_carry_forward' => $evaluation['last_name_exception_carry_forward'] ?? null,
],
];
}
private function filterEnrollmentPayloadByColumns(array $payload): array
{
foreach (array_keys($payload) as $column) {
if (! $this->db->fieldExists($column, 'enrollments')) {
unset($payload[$column]);
}
}
return $payload;
}
private function hasAcceptedPolicyForYear(int $parentId, string $schoolYear): bool
{
if ($parentId <= 0 || $schoolYear === '') {
@@ -1243,7 +1374,8 @@ class ParentController extends BaseController
try {
$studentName = $this->studentNameForEnrollmentMessage($studentId);
$evaluation = service('enrollmentTransition')->evaluate($studentId, $previousSchoolYear, $targetSchoolYear, 'parent');
$parentId = (int) session()->get('user_id');
$evaluation = service('enrollmentTransition')->evaluateForParent($parentId, $studentId, $previousSchoolYear, $targetSchoolYear, 'parent');
foreach ($evaluation['blockers'] ?? [] as $blocker) {
$blocker = trim((string) $blocker);
if ($blocker !== '') {
@@ -1268,10 +1400,10 @@ class ParentController extends BaseController
return EnrollmentEligibility::parentDecisionMessage($student, $decisionRow, $targetSchoolYear, $fallMakeupExamOn);
}
private function transitionEvaluationForStudent(int $studentId, string $previousSchoolYear, string $selectedYear): ?array
private function transitionEvaluationForStudent(int $parentId, int $studentId, string $previousSchoolYear, string $selectedYear): ?array
{
try {
return service('enrollmentTransition')->evaluate($studentId, $previousSchoolYear, $selectedYear, 'parent');
return service('enrollmentTransition')->evaluateForParent($parentId, $studentId, $previousSchoolYear, $selectedYear, 'parent');
} catch (\Throwable $e) {
log_message('error', 'Enrollment transition evaluation failed for student ' . $studentId . ': ' . $e->getMessage());
@@ -1317,6 +1449,14 @@ class ParentController extends BaseController
);
}
if (! empty($evaluation['can_enroll']) && ! empty($evaluation['admin_exception'])) {
return [
'message' => 'Enrollment has been authorized by administration.',
'blocking' => false,
'level' => 'info',
];
}
$blockers = array_values(array_filter(array_map('trim', array_map('strval', $evaluation['blockers'] ?? []))));
if ($blockers !== []) {
$name = $this->studentNameFromRow($student);
@@ -1409,31 +1549,80 @@ class ParentController extends BaseController
private function requiredActionLabel(?array $evaluation): string
{
if ($evaluation === null) {
return 'Complete re-enrollment before the registration deadline.';
return 'Contact administration';
}
if (($evaluation['blockers'] ?? []) !== []) {
if (($evaluation['adult_student'] ?? false) && ! ($evaluation['parent_enrollment_allowed'] ?? false)) {
return 'Student must complete the authorized adult-student process or contact administration.';
$state = $this->parentEnrollmentStateFromEvaluation($evaluation, null);
return match ($state) {
'Enroll' => 'Complete re-enrollment before the registration deadline.',
'Eligible with follow-up' => 'Complete re-enrollment and follow the listed next step.',
'Already submitted' => 'Already submitted',
'Action needed' => 'Action needed: pay the previous-year balance or contact administration.',
'Under review' => 'Under review. Contact the school administration.',
default => 'Contact administration',
};
}
private function parentEnrollmentState(array $student): string
{
$status = strtolower(trim((string) ($student['enrollment_status'] ?? '')));
if (in_array($status, [
'admission under review',
'review & decision',
'payment pending',
'enrolled',
'waitlist',
'withdraw under review',
], true)) {
return 'Already submitted';
}
return $this->parentEnrollmentStateFromEvaluation(
is_array($student['transition_evaluation'] ?? null) ? $student['transition_evaluation'] : null,
$status
);
}
private function parentEnrollmentStateFromEvaluation(?array $evaluation, ?string $enrollmentStatus): string
{
if ($evaluation === null) {
return 'Contact administration';
}
$decision = (string) ($evaluation['decision'] ?? '');
$codes = array_map('strval', $evaluation['blocking_rule_codes'] ?? []);
if ($decision === 'ALREADY_ENROLLED' || $enrollmentStatus === 'already enrolled') {
return 'Already submitted';
}
if (! empty($evaluation['can_enroll']) || $decision === 'EXCEPTION_ELIGIBLE' || $decision === 'ELIGIBLE') {
if ($decision === 'ELIGIBLE_WITH_WARNING' || ($evaluation['warning_rule_codes'] ?? []) !== []) {
return 'Eligible with follow-up';
}
$decision = (string) ($evaluation['deliberation_decision'] ?? '');
if (in_array($decision, [
DeliberationDecision::EXPELLED,
DeliberationDecision::WITHDRAWN,
DeliberationDecision::DEFERRED_DECISION,
], true)) {
return 'Contact the school administration.';
}
return 'Review the eligibility message above.';
return 'Enroll';
}
if (($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM) {
return 'Complete re-enrollment and follow make-up exam instructions.';
if ($decision === 'ELIGIBLE_WITH_WARNING') {
return 'Eligible with follow-up';
}
return 'Complete re-enrollment before the registration deadline.';
if (in_array('OUTSTANDING_BALANCE_BLOCKED', $codes, true)
|| in_array('FINANCE_APPROVAL_REQUIRED', $codes, true)
|| in_array('SIBLING_LAST_NAME_MISMATCH', $codes, true)
) {
return 'Action needed';
}
if ($decision === 'REVIEW_REQUIRED' || in_array((string) ($evaluation['deliberation_decision'] ?? ''), [
DeliberationDecision::EXPELLED,
DeliberationDecision::WITHDRAWN,
DeliberationDecision::DEFERRED_DECISION,
], true)) {
return 'Under review';
}
return 'Contact administration';
}
private function familyFinancialSummary(int $parentId, ?string $previousSchoolYear, string $selectedYear): array
@@ -1444,7 +1633,7 @@ class ParentController extends BaseController
$registrationFee = round((float) ($schoolYearConfig['registration_fee'] ?? 0), 2);
$tuitionDue = round((float) ($schoolYearConfig['tuition_due_at_registration'] ?? 0), 2);
$mandatoryFees = round((float) ($schoolYearConfig['mandatory_fees'] ?? 0), 2);
$behavior = (string) ($schoolYearConfig['carry_over_balance_behavior'] ?? 'information_only');
$behavior = (string) ($schoolYearConfig['carry_over_balance_behavior'] ?? 'submission_blocked_until_payment');
$amountDue = max(0.0, $carryOver) + $registrationFee + $tuitionDue + $mandatoryFees;
return [
@@ -1461,21 +1650,6 @@ class ParentController extends BaseController
];
}
private function financialSubmissionBlockers(int $parentId, string $selectedYear): array
{
$previousSchoolYear = $this->previousSchoolYearName($selectedYear);
$summary = $this->familyFinancialSummary($parentId, $previousSchoolYear, $selectedYear);
if (($summary['carry_over_balance'] ?? 0.0) <= 0.0) {
return [];
}
return match ((string) ($summary['balance_behavior'] ?? 'information_only')) {
'submission_blocked_until_payment' => ['Registration cannot be submitted until the previous-year balance is paid.'],
'admin_approval_required' => ['Registration requires administrative financial approval because there is a previous-year balance.'],
default => [],
};
}
private function financialPolicyMessage(string $behavior, string $configured): string
{
$configured = trim($configured);
@@ -1488,7 +1662,7 @@ class ParentController extends BaseController
'submission_allowed_confirmation_blocked' => 'Registration may be submitted, but it will not be confirmed until the balance is settled.',
'submission_blocked_until_payment' => 'The balance must be paid before registration can be submitted.',
'admin_approval_required' => 'Please contact the finance office to arrange an approved exception.',
default => 'The balance is shown for information and does not currently block registration.',
default => 'The previous-year balance must be paid before registration can be submitted.',
};
}
@@ -1784,6 +1958,10 @@ class ParentController extends BaseController
public function profile($id)
{
if (! $this->canAccessUserRecord((int) $id)) {
return redirect()->to('/access_denied');
}
// Fetch the user's data based on the given ID
$user = $this->userModel->find($id);
@@ -1798,6 +1976,10 @@ class ParentController extends BaseController
public function updateProfile($id)
{
if (! $this->canAccessUserRecord((int) $id)) {
return redirect()->to('/access_denied');
}
$user = $this->userModel->find($id);
// Step 1: Check if user exists
@@ -2937,4 +3119,22 @@ $existing = $this->studentModel
return redirect()->back()->with('success', 'Participation updated');
}
private function canAccessUserRecord(int $id): bool
{
$userId = (int) (session()->get('user_id') ?? 0);
if ($userId <= 0 || $id <= 0) {
return false;
}
if ($userId === $id) {
return true;
}
$roles = array_map(
static fn ($role): string => strtolower(trim((string) $role)),
array_filter(array_merge((array) session()->get('roles'), [session()->get('role')]))
);
return (bool) array_intersect($roles, ['administrator', 'administrative staff', 'principal', 'admin']);
}
}
@@ -0,0 +1,84 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\ConfigurationModel;
use App\Models\FinancialAidRequestModel;
use App\Models\StudentModel;
class ParentFinancialAidController extends BaseController
{
public function index()
{
$parentId = (int) session()->get('user_id');
if ($parentId <= 0) {
return redirect()->to('/login');
}
$schoolYear = (string) ((new ConfigurationModel())->getConfig('school_year') ?? '');
$model = new FinancialAidRequestModel();
$requests = $model
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->orderBy('id', 'DESC')
->findAll();
$students = (new StudentModel())
->where('parent_id', $parentId)
->orderBy('lastname', 'ASC')
->orderBy('firstname', 'ASC')
->findAll();
return view('parent/financial_aid', [
'schoolYear' => $schoolYear,
'students' => $students,
'requests' => $requests,
'openRequest' => $model->openRequestForParent($parentId, $schoolYear),
]);
}
public function submit()
{
$parentId = (int) session()->get('user_id');
if ($parentId <= 0) {
return redirect()->to('/login');
}
$schoolYear = (string) ((new ConfigurationModel())->getConfig('school_year') ?? '');
$model = new FinancialAidRequestModel();
if ($model->openRequestForParent($parentId, $schoolYear) !== null) {
return redirect()->back()->with('error', 'You already have an open financial aid request for this school year.');
}
$studentIds = array_values(array_unique(array_filter(array_map('intval', (array) $this->request->getPost('student_ids')))));
$linkedIds = array_map('intval', array_column(
(new StudentModel())->select('id')->where('parent_id', $parentId)->findAll(),
'id'
));
$studentIds = array_values(array_intersect($studentIds, $linkedIds));
if ($studentIds === []) {
return redirect()->back()->withInput()->with('error', 'Select at least one of your students.');
}
$needStatement = trim((string) $this->request->getPost('need_statement'));
if ($needStatement === '') {
return redirect()->back()->withInput()->with('error', 'Please describe why you are requesting financial aid.');
}
$householdSize = (int) $this->request->getPost('household_size');
$requestedAmount = trim((string) $this->request->getPost('requested_amount'));
$model->insert([
'parent_id' => $parentId,
'school_year' => $schoolYear,
'student_ids_json' => json_encode($studentIds),
'household_size' => $householdSize > 0 ? $householdSize : null,
'need_statement' => $needStatement,
'requested_amount' => $requestedAmount !== '' ? (float) $requestedAmount : null,
'status' => 'submitted',
]);
return redirect()->to('/parent/financial-aid')->with('success', 'Your financial aid request was submitted.');
}
}
+31 -8
View File
@@ -20,13 +20,16 @@ class PreferencesController extends BaseController
*/
public function index($userId = null)
{
// Get user ID from parameter or session
$userId = $userId ?? (int) session()->get('user_id');
if (!$userId) {
$sessionUserId = (int) (session()->get('user_id') ?? 0);
if ($sessionUserId <= 0) {
return redirect()->to('/login')->with('error', 'Please log in to view preferences');
}
$userId = (int) ($userId ?? $sessionUserId);
if (! $this->canAccessUserPreferences($userId, $sessionUserId)) {
return redirect()->to('/access_denied');
}
// Fetch preferences for the current user
$preferences = $this->preferencesModel->where('user_id', $userId)->first();
@@ -64,13 +67,16 @@ class PreferencesController extends BaseController
*/
public function updatePreferences($userId = null)
{
// Get user ID from parameter or session
$userId = $userId ?? (int) session()->get('user_id');
if (!$userId) {
$sessionUserId = (int) (session()->get('user_id') ?? 0);
if ($sessionUserId <= 0) {
return redirect()->to('/login')->with('error', 'Please log in to update preferences');
}
$userId = (int) ($userId ?? $sessionUserId);
if (! $this->canAccessUserPreferences($userId, $sessionUserId)) {
return redirect()->to('/access_denied');
}
// Validation rules
$validation = \Config\Services::validation();
@@ -141,4 +147,21 @@ class PreferencesController extends BaseController
// Redirect back to preferences page with success message
return redirect()->to('/preferences/' . $userId)->with('success', 'Preferences updated successfully');
}
private function canAccessUserPreferences(int $requestedUserId, int $sessionUserId): bool
{
if ($requestedUserId <= 0 || $sessionUserId <= 0) {
return false;
}
if ($requestedUserId === $sessionUserId) {
return true;
}
$roles = array_map(
static fn ($role): string => strtolower(trim((string) $role)),
array_filter(array_merge((array) session()->get('roles'), [session()->get('role')]))
);
return (bool) array_intersect($roles, ['administrator', 'administrative staff', 'principal', 'admin']);
}
}
+214 -49
View File
@@ -914,28 +914,37 @@ class StudentController extends BaseController
}
$total = count($cands);
if ($sectionCount * $minPerSection > $total) {
$msg = 'Insufficient students: ' . $sectionCount . ' sections require at least ' . ($sectionCount * $minPerSection) . ' students, but only ' . $total . ' are available.';
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
}
if ($maxPerSection !== null && $total > $sectionCount * $maxPerSection) {
$msg = 'Capacity exceeded: ' . $sectionCount . ' sections can hold at most ' . ($sectionCount * $maxPerSection) . ' students, but ' . $total . ' must be assigned.';
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
}
// Fetch lettered sections for this class
$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);
if (empty($letters)) {
$msg = 'No lettered sections found for the selected class.';
$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);
}
if (count($letters) < $sectionCount) {
$msg = 'Not enough sections available. Needed: ' . $sectionCount . ', available: ' . count($letters);
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);
}
$letters = array_slice($letters, 0, $sectionCount);
if ($actualSectionCount < 2) {
$letters = [$baseSection];
} else {
$letters = array_slice($letters, 0, $actualSectionCount);
}
$buckets = $this->buildBalancedDistribution($cands, $letters, $minPerSection, $maxPerSection);
$draftModel = new StudentSectionDistributionDraftModel();
@@ -946,21 +955,11 @@ class StudentController extends BaseController
$draftIdByStudentId = [];
$this->db->transStart();
$studentIdsToReplace = array_values(array_unique(array_map(
static fn(array $student): int => (int)($student['student_id'] ?? 0),
$cands
)));
if (!empty($studentIdsToReplace)) {
$draftModel->where('school_year', $year)
->whereIn('student_id', $studentIdsToReplace)
->where('status', 'pending')
->delete();
}
foreach ($buckets as $b) {
$secId = (int)$b['class_section_id'];
foreach ($b['assigned'] as $student) {
$sid = (int)$student['student_id'];
$draftId = (int)$draftModel->insert([
$draftId = $this->upsertDistributionDraft($draftModel, [
'student_id' => $sid,
'class_id' => $classId,
'class_section_id' => $secId,
@@ -971,7 +970,6 @@ class StudentController extends BaseController
'status' => 'pending',
'batch_key' => $batchKey,
'created_by' => $updatedBy,
'created_at' => $now,
'updated_at' => $now,
]);
if ($draftId > 0) {
@@ -1026,6 +1024,7 @@ class StudentController extends BaseController
'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,
@@ -1182,12 +1181,7 @@ class StudentController extends BaseController
$now = utc_now();
$this->db->transStart();
$draftModel->where('student_id', $studentId)
->where('school_year', $year)
->where('status', 'pending')
->delete();
$draftId = (int)$draftModel->insert([
$draftId = $this->upsertDistributionDraft($draftModel, [
'student_id' => $studentId,
'class_id' => $targetClassId,
'class_section_id' => $targetSectionId,
@@ -1198,7 +1192,6 @@ class StudentController extends BaseController
'status' => 'pending',
'batch_key' => sha1($year . ':' . $studentId . ':' . microtime(true)),
'created_by' => $updatedBy,
'created_at' => $now,
'updated_at' => $now,
]);
@@ -1286,7 +1279,8 @@ class StudentController extends BaseController
return $this->mergeDistributionCandidates(
$out,
$this->decisionDistributionCandidates($classId, $year),
$this->currentYearDistributionCandidates($classId, $year)
$this->currentYearDistributionCandidates($classId, $year),
$this->registeredKgDistributionCandidates($classId, $year)
);
}
@@ -1302,7 +1296,7 @@ class StudentController extends BaseController
$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', 'left')
->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);
@@ -1325,7 +1319,7 @@ class StudentController extends BaseController
$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', 'left')
->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()
@@ -1413,6 +1407,40 @@ class StudentController extends BaseController
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')) {
@@ -1422,7 +1450,7 @@ class StudentController extends BaseController
$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', 'left')
->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()
@@ -1515,7 +1543,12 @@ class StudentController extends BaseController
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)) {
$builder->where('year_of_registration', (int)$matches[1]);
$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')) {
@@ -1537,6 +1570,10 @@ class StudentController extends BaseController
continue;
}
if (!$this->studentHasOnlyKgPriorPlacement($studentId, $year)) {
continue;
}
$ageAtReference = $this->distributionAgeAtReference($row['dob'] ?? null, $year);
$targetClassId = $this->distributionTargetClassIdForStudent(
$classId,
@@ -1565,6 +1602,72 @@ class StudentController extends BaseController
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) {
@@ -1722,7 +1825,8 @@ class StudentController extends BaseController
$targetClassId = $this->targetClassIdFromDecision(
(string)($row['class_section_name'] ?? ''),
(string)($row['decision'] ?? '')
(string)($row['decision'] ?? ''),
$targetSchoolYear
);
$ageAtReference = $this->distributionAgeAtReference($row['dob'] ?? null, $targetSchoolYear);
$targetClassId = $this->distributionTargetClassIdForStudent(
@@ -1754,7 +1858,7 @@ class StudentController extends BaseController
return $out;
}
private function targetClassIdFromDecision(string $classSectionName, string $decision): ?int
private function targetClassIdFromDecision(string $classSectionName, string $decision, string $targetSchoolYear = ''): ?int
{
$baseName = strtoupper(trim(preg_replace('/-.+$/', '', $classSectionName) ?? ''));
if ($baseName === '') {
@@ -1767,17 +1871,30 @@ class StudentController extends BaseController
$targetBaseName = '1';
} elseif (ctype_digit($baseName)) {
$level = (int)$baseName;
$targetBaseName = $level >= 9 ? 'YOUTH' : (string)($level + 1);
$targetBaseName = $level >= 10 ? 'YOUTH' : (string)($level + 1);
} elseif ($baseName === 'YOUTH') {
$targetBaseName = 'YOUTH';
}
}
$row = $this->classSectionModel
$query = $this->classSectionModel
->select('class_id')
->where('UPPER(class_section_name)', $targetBaseName)
->where("class_section_name NOT LIKE '%-%'", null, false)
->first();
->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;
}
@@ -1884,7 +2001,7 @@ class StudentController extends BaseController
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', 'left')
->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);
@@ -1911,7 +2028,7 @@ class StudentController extends BaseController
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', 'left')
->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)
@@ -2236,6 +2353,7 @@ class StudentController extends BaseController
'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,
@@ -2277,7 +2395,26 @@ class StudentController extends BaseController
}
$timezone = new \DateTimeZone((string)(config('School')->attendance['timezone'] ?? user_timezone()));
$reference = new \DateTimeImmutable($matches[1] . '-09-01', $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);
}
@@ -2312,6 +2449,10 @@ class StudentController extends BaseController
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;
@@ -2474,8 +2615,8 @@ class StudentController extends BaseController
}
$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, 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', 'left')
->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)
@@ -2511,6 +2652,10 @@ class StudentController extends BaseController
'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' => [],
];
@@ -2520,6 +2665,16 @@ class StudentController extends BaseController
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),
@@ -2527,6 +2682,7 @@ class StudentController extends BaseController
'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,
@@ -2538,6 +2694,15 @@ class StudentController extends BaseController
$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);
}