fix enrollment logic, add financial aid, fix class distribution
Tests / PHPUnit (push) Failing after 1m6s
Tests / PHPUnit (push) Failing after 1m6s
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user