1328 lines
55 KiB
PHP
1328 lines
55 KiB
PHP
<?php
|
|
|
|
namespace App\Controllers\View;
|
|
|
|
use App\Controllers\BaseController;
|
|
use CodeIgniter\Database\BaseConnection;
|
|
use CodeIgniter\HTTP\RequestInterface;
|
|
use CodeIgniter\HTTP\ResponseInterface;
|
|
use Psr\Log\LoggerInterface;
|
|
use Throwable;
|
|
use App\Support\Enrollment\DeliberationDecision;
|
|
|
|
class EnrollmentAdminController extends BaseController
|
|
{
|
|
protected BaseConnection $db;
|
|
|
|
public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger)
|
|
{
|
|
parent::initController($request, $response, $logger);
|
|
|
|
$this->db = \Config\Database::connect();
|
|
}
|
|
|
|
public function dashboard()
|
|
{
|
|
$schoolYear = trim((string) ($this->request->getGet('school_year') ?? $this->currentSchoolYearName((string) ($this->schoolYear ?? ''))));
|
|
$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,
|
|
'schoolYear' => $schoolYear,
|
|
'status' => $status,
|
|
'flagType' => $flagType,
|
|
'assignedTo' => $assignedTo,
|
|
'flagTypes' => $this->flagTypes(),
|
|
'schoolYears' => $this->schoolYears(),
|
|
'classSections' => $this->classSections($schoolYear),
|
|
'admins' => $this->adminUsers(),
|
|
'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),
|
|
]);
|
|
}
|
|
|
|
public function approveLaunch()
|
|
{
|
|
$schoolYear = trim((string) ($this->request->getPost('school_year') ?? ''));
|
|
if ($schoolYear === '') {
|
|
return redirect()->back()->with('error', 'School year is required.');
|
|
}
|
|
|
|
if (! $this->launchConfigurationComplete($schoolYear, $missing)) {
|
|
return redirect()->back()->with('error', 'Registration launch is not ready: ' . implode(', ', $missing));
|
|
}
|
|
|
|
$this->db->table('school_years')
|
|
->where('name', $schoolYear)
|
|
->update([
|
|
'registration_launch_approved_at' => date('Y-m-d H:i:s'),
|
|
'registration_launch_approved_by' => $this->userId(),
|
|
'registration_email_template_version' => \App\Services\EnrollmentRegistrationEmailService::TEMPLATE_VERSION,
|
|
'updated_at' => date('Y-m-d H:i:s'),
|
|
]);
|
|
|
|
return redirect()->back()->with('success', 'Registration launch approved for ' . $schoolYear . '.');
|
|
}
|
|
|
|
public function sendRegistrationEmails()
|
|
{
|
|
$schoolYear = trim((string) ($this->request->getPost('school_year') ?? ''));
|
|
if ($schoolYear === '') {
|
|
return redirect()->back()->with('error', 'School year is required.');
|
|
}
|
|
|
|
if (! $this->launchConfigurationComplete($schoolYear, $missing)) {
|
|
return redirect()->back()->with('error', 'Registration launch is not ready: ' . implode(', ', $missing));
|
|
}
|
|
|
|
$force = (bool) $this->request->getPost('force_resend');
|
|
$result = service('enrollmentRegistrationEmail')->sendForSchoolYearName($schoolYear, $force);
|
|
$message = sprintf(
|
|
'Registration emails processed for %s: %d sent, %d failed, %d skipped.',
|
|
$schoolYear,
|
|
(int) ($result['sent'] ?? 0),
|
|
(int) ($result['failed'] ?? 0),
|
|
(int) ($result['skipped'] ?? 0)
|
|
);
|
|
|
|
$details = array_filter(array_map('strval', $result['messages'] ?? []));
|
|
if ($details !== []) {
|
|
$message .= ' ' . implode(' ', $details);
|
|
}
|
|
|
|
return redirect()->back()->with(((int) ($result['failed'] ?? 0) > 0) ? 'error' : 'success', $message);
|
|
}
|
|
|
|
public function previewEmail()
|
|
{
|
|
$schoolYear = trim((string) ($this->request->getGet('school_year') ?? ''));
|
|
$parentId = (int) ($this->request->getGet('parent_id') ?? 0);
|
|
if ($schoolYear === '' || $parentId <= 0) {
|
|
return redirect()->back()->with('error', 'School year and parent are required for preview.');
|
|
}
|
|
|
|
$message = service('enrollmentRegistrationEmail')->previewForParent($schoolYear, $parentId);
|
|
if ($message === null) {
|
|
return redirect()->back()->with('error', 'No preview email could be generated for that parent.');
|
|
}
|
|
|
|
return view('administrator/enrollment_email_preview', [
|
|
'schoolYear' => $schoolYear,
|
|
'parentId' => $parentId,
|
|
'subject' => $message['subject'],
|
|
'body' => $message['body'],
|
|
]);
|
|
}
|
|
|
|
public function resolveFlag(int $id)
|
|
{
|
|
try {
|
|
$flag = $this->requireFlag($id);
|
|
$notes = trim((string) ($this->request->getPost('resolution_notes') ?? ''));
|
|
if ($notes === '') {
|
|
return redirect()->back()->with('error', 'Resolution notes are required.');
|
|
}
|
|
|
|
$this->resolveFlagRow($flag, $notes, 'flag_resolved');
|
|
return redirect()->back()->with('success', 'Enrollment flag resolved.');
|
|
} catch (Throwable $e) {
|
|
return redirect()->back()->with('error', $e->getMessage());
|
|
}
|
|
}
|
|
|
|
public function assignClass(int $id)
|
|
{
|
|
try {
|
|
$flag = $this->requireFlag($id);
|
|
$sectionId = (int) ($this->request->getPost('class_section_id') ?? 0);
|
|
$notes = trim((string) ($this->request->getPost('resolution_notes') ?? ''));
|
|
if ($sectionId <= 0) {
|
|
return redirect()->back()->with('error', 'Select a target class section.');
|
|
}
|
|
if ($notes === '') {
|
|
return redirect()->back()->with('error', 'Resolution notes are required.');
|
|
}
|
|
|
|
$this->applyClassSection((int) $flag['student_id'], (string) $flag['school_year'], $sectionId, 'manual_class_assignment');
|
|
$this->resolveFlagRow($flag, $notes, 'manual_class_assignment', ['class_section_id' => $sectionId]);
|
|
|
|
return redirect()->back()->with('success', 'Class assignment applied.');
|
|
} catch (Throwable $e) {
|
|
return redirect()->back()->with('error', $e->getMessage());
|
|
}
|
|
}
|
|
|
|
public function confirmMakeupPromotion(int $id)
|
|
{
|
|
try {
|
|
$flag = $this->requireFlag($id);
|
|
if ((string) ($flag['flag_type'] ?? '') !== 'PENDING_MAKE_UP_EXAM_PROMOTION') {
|
|
return redirect()->back()->with('error', 'This flag is not a make-up exam promotion flag.');
|
|
}
|
|
|
|
$sectionId = (int) ($this->request->getPost('class_section_id') ?? 0);
|
|
$examResult = trim((string) ($this->request->getPost('exam_result') ?? ''));
|
|
$notes = trim((string) ($this->request->getPost('resolution_notes') ?? ''));
|
|
if (! in_array($examResult, ['passed', 'failed'], true)) {
|
|
return redirect()->back()->with('error', 'Select whether the make-up exam was passed or failed.');
|
|
}
|
|
if ($notes === '') {
|
|
return redirect()->back()->with('error', 'Resolution notes are required.');
|
|
}
|
|
|
|
if ($examResult === 'passed') {
|
|
if ($sectionId <= 0) {
|
|
return redirect()->back()->with('error', 'Select the promoted class section.');
|
|
}
|
|
|
|
$this->applyClassSection((int) $flag['student_id'], (string) $flag['school_year'], $sectionId, 'make_up_exam_promotion_completed', 'promotion_completed');
|
|
$this->resolveFlagRow($flag, $notes, 'make_up_exam_promotion_completed', [
|
|
'exam_result' => $examResult,
|
|
'class_section_id' => $sectionId,
|
|
]);
|
|
} else {
|
|
$this->updateEnrollmentPlacementStatus((int) $flag['student_id'], (string) $flag['school_year'], 'no_promotion_required');
|
|
$this->resolveFlagRow($flag, $notes, 'make_up_exam_no_promotion_required', [
|
|
'exam_result' => $examResult,
|
|
]);
|
|
}
|
|
|
|
return redirect()->back()->with('success', 'Make-up exam follow-up resolved.');
|
|
} catch (Throwable $e) {
|
|
return redirect()->back()->with('error', $e->getMessage());
|
|
}
|
|
}
|
|
|
|
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');
|
|
|
|
$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' => null,
|
|
'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' => $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') ?? ''));
|
|
$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.');
|
|
}
|
|
|
|
$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, ['ADULT_STUDENT_PARENT_BLOCKED']));
|
|
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' => null,
|
|
'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')) {
|
|
return [];
|
|
}
|
|
|
|
$builder = $this->db->table('enrollment_flags ef')
|
|
->select('ef.*')
|
|
->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');
|
|
|
|
if ($schoolYear !== '') {
|
|
$builder->where('ef.school_year', $schoolYear);
|
|
}
|
|
if ($status !== '') {
|
|
$builder->where('ef.status', $status);
|
|
}
|
|
if ($flagType !== '') {
|
|
$builder->where('ef.flag_type', $flagType);
|
|
}
|
|
if (is_numeric($assignedTo) && (int) $assignedTo > 0) {
|
|
$builder->where('ef.assigned_to', (int) $assignedTo);
|
|
}
|
|
$ignoredFlagTypes = ['CLASS_CAPACITY_EXCEPTION_REQUIRED', 'CLASS_REASSIGNMENT_REQUIRED'];
|
|
$builder->whereNotIn('ef.flag_type', $ignoredFlagTypes);
|
|
|
|
$rows = $builder->get()->getResultArray();
|
|
$rows = array_values(array_filter(
|
|
$rows,
|
|
static fn (array $row): bool => ! in_array((string) ($row['flag_type'] ?? ''), $ignoredFlagTypes, true)
|
|
));
|
|
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) ?: [];
|
|
if (! isset($row['details']['rule_code'])) {
|
|
$ruleCode = $this->ruleCodeForExistingFlag($row);
|
|
if ($ruleCode !== null) {
|
|
$row['details']['rule_code'] = $ruleCode;
|
|
}
|
|
}
|
|
}
|
|
unset($row);
|
|
|
|
return $rows;
|
|
}
|
|
|
|
private function ruleCodeForExistingFlag(array $flag): ?string
|
|
{
|
|
$type = strtoupper(trim((string) ($flag['flag_type'] ?? '')));
|
|
if (! in_array($type, ['DEFERRED_DELIBERATION', 'RESTRICTED_ADMINISTRATIVE_REVIEW', 'WITHDRAWAL_REVIEW_REQUIRED', 'PENDING_MAKE_UP_EXAM_PROMOTION'], true)) {
|
|
return null;
|
|
}
|
|
|
|
$studentId = (int) ($flag['student_id'] ?? 0);
|
|
$sourceSchoolYear = trim((string) ($flag['source_school_year'] ?? ''));
|
|
if ($studentId <= 0 || $sourceSchoolYear === '' || ! $this->db->tableExists('student_decisions')) {
|
|
return $type === 'WITHDRAWAL_REVIEW_REQUIRED' ? 'WITHDRAWN' : null;
|
|
}
|
|
|
|
$select = ['decision'];
|
|
if ($this->db->fieldExists('deliberation_decision_standard', 'student_decisions')) {
|
|
$select[] = 'deliberation_decision_standard';
|
|
}
|
|
|
|
$row = $this->db->table('student_decisions')
|
|
->select($select)
|
|
->where('student_id', $studentId)
|
|
->where('school_year', $sourceSchoolYear)
|
|
->orderBy('updated_at', 'DESC')
|
|
->orderBy('id', 'DESC')
|
|
->limit(1)
|
|
->get()
|
|
->getRowArray();
|
|
|
|
if ($row === null) {
|
|
return match ($type) {
|
|
'DEFERRED_DELIBERATION' => 'NO_FINAL_DECISION',
|
|
'WITHDRAWAL_REVIEW_REQUIRED' => 'WITHDRAWN',
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
$decision = DeliberationDecision::normalize($row['deliberation_decision_standard'] ?? null)
|
|
?? DeliberationDecision::normalize($row['decision'] ?? null);
|
|
|
|
return match ($decision) {
|
|
DeliberationDecision::DEFERRED_DECISION => 'DEFERRED_DECISION',
|
|
DeliberationDecision::EXPELLED => 'EXPELLED',
|
|
DeliberationDecision::WITHDRAWN => 'WITHDRAWN',
|
|
DeliberationDecision::MAKE_UP_EXAM => 'MAKE_UP_EXAM',
|
|
null => $type === 'DEFERRED_DELIBERATION' ? 'UNRECOGNIZED_DECISION' : null,
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
private function enrollmentFollowups(string $schoolYear, array $flags = []): array
|
|
{
|
|
if (! $this->db->tableExists('enrollments')) {
|
|
return $this->followupsFromFlags($flags, []);
|
|
}
|
|
|
|
$fields = $this->db->getFieldNames('enrollments');
|
|
$select = [
|
|
'e.id',
|
|
'e.student_id',
|
|
'e.school_year',
|
|
'e.enrollment_status',
|
|
'e.class_section_id',
|
|
'e.updated_at',
|
|
's.firstname',
|
|
's.lastname',
|
|
's.school_id',
|
|
'cs.class_section_name',
|
|
];
|
|
|
|
foreach ([
|
|
'deliberation_decision',
|
|
'placement_status',
|
|
'exception_required',
|
|
'exception_reason',
|
|
'source_school_year',
|
|
'assigned_class_section_id',
|
|
'age_on_reference_date',
|
|
] as $field) {
|
|
if (in_array($field, $fields, true)) {
|
|
$select[] = 'e.' . $field;
|
|
}
|
|
}
|
|
|
|
$builder = $this->db->table('enrollments e')
|
|
->select(implode(', ', $select))
|
|
->join('students s', 's.id = e.student_id', 'left')
|
|
->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left')
|
|
->orderBy('e.updated_at', 'DESC')
|
|
->orderBy('e.id', 'DESC')
|
|
->limit(200);
|
|
|
|
if ($schoolYear !== '') {
|
|
$builder->where('e.school_year', $schoolYear);
|
|
}
|
|
|
|
$builder->groupStart()
|
|
->whereIn('e.enrollment_status', [
|
|
'review & decision',
|
|
'admission under review',
|
|
'waitlist',
|
|
'denied',
|
|
'Review & Decision',
|
|
'Admission Under Review',
|
|
'Waitlist',
|
|
'Denied',
|
|
]);
|
|
|
|
if (in_array('placement_status', $fields, true)) {
|
|
$builder->orWhereIn('e.placement_status', [
|
|
'temporary_same_grade',
|
|
'temporary_manual_class_required',
|
|
'manual_class_required',
|
|
'exit_required',
|
|
'automatic_distribution_pending',
|
|
]);
|
|
}
|
|
|
|
if (in_array('exception_required', $fields, true)) {
|
|
$builder->orWhere('e.exception_required', 1);
|
|
}
|
|
|
|
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',
|
|
]);
|
|
}
|
|
|
|
$rows = $builder->groupEnd()->get()->getResultArray();
|
|
foreach ($rows as &$row) {
|
|
$row['student_name'] = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')) ?: 'Student #' . (int) ($row['student_id'] ?? 0);
|
|
}
|
|
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',
|
|
'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',
|
|
'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')) {
|
|
throw new \RuntimeException('Enrollment flag was not found.');
|
|
}
|
|
|
|
$flag = $this->db->table('enrollment_flags')->where('id', $id)->limit(1)->get()->getRowArray();
|
|
if ($flag === null) {
|
|
throw new \RuntimeException('Enrollment flag was not found.');
|
|
}
|
|
|
|
return $flag;
|
|
}
|
|
|
|
private function resolveFlagRow(array $flag, string $notes, string $auditAction, array $metadata = []): void
|
|
{
|
|
$this->db->transStart();
|
|
$original = $flag;
|
|
$this->db->table('enrollment_flags')
|
|
->where('id', (int) $flag['id'])
|
|
->update([
|
|
'status' => 'resolved',
|
|
'resolved_at' => date('Y-m-d H:i:s'),
|
|
'resolution_notes' => $notes,
|
|
]);
|
|
|
|
$this->audit((int) $flag['student_id'], (string) $flag['school_year'], (string) ($flag['source_school_year'] ?? ''), $auditAction, $original, array_merge($metadata, [
|
|
'flag_id' => (int) $flag['id'],
|
|
'flag_type' => (string) $flag['flag_type'],
|
|
'resolution_notes' => $notes,
|
|
]), $notes);
|
|
$this->db->transComplete();
|
|
|
|
if ($this->db->transStatus() === false) {
|
|
throw new \RuntimeException('Unable to resolve enrollment flag.');
|
|
}
|
|
}
|
|
|
|
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')
|
|
->select('class_section_id, class_id, class_section_name')
|
|
->where('class_section_id', $sectionId)
|
|
->orderBy('id', 'DESC')
|
|
->limit(1)
|
|
->get()
|
|
->getRowArray();
|
|
if ($section === null) {
|
|
throw new \RuntimeException('Selected class section was not found.');
|
|
}
|
|
|
|
$originalEnrollment = service('enrollmentStatus')->controllingEnrollment($studentId, $schoolYear);
|
|
$payload = [
|
|
'class_section_id' => $sectionId,
|
|
'assigned_class_section_id' => $sectionId,
|
|
'assigned_grade_id' => (int) ($section['class_id'] ?? 0) ?: null,
|
|
'placement_status' => $placementStatus,
|
|
'updated_at' => date('Y-m-d H:i:s'),
|
|
];
|
|
|
|
$this->db->table('enrollments')
|
|
->where('student_id', $studentId)
|
|
->where('school_year', $schoolYear)
|
|
->update($payload);
|
|
|
|
$studentClass = $this->db->table('student_class')
|
|
->where('student_id', $studentId)
|
|
->where('school_year', $schoolYear)
|
|
->orderBy('id', 'DESC')
|
|
->limit(1)
|
|
->get()
|
|
->getRowArray();
|
|
$studentClassPayload = [
|
|
'student_id' => $studentId,
|
|
'class_section_id' => $sectionId,
|
|
'school_year' => $schoolYear,
|
|
'updated_by' => $this->userId(),
|
|
'updated_at' => date('Y-m-d H:i:s'),
|
|
];
|
|
if ($studentClass !== null) {
|
|
$this->db->table('student_class')->where('id', (int) $studentClass['id'])->update($studentClassPayload);
|
|
} else {
|
|
$studentClassPayload['created_at'] = date('Y-m-d H:i:s');
|
|
$this->db->table('student_class')->insert($studentClassPayload);
|
|
}
|
|
|
|
$this->audit($studentId, $schoolYear, (string) ($originalEnrollment['source_school_year'] ?? ''), $auditAction, $originalEnrollment, $payload, 'Class section assigned by administrator.');
|
|
|
|
$parentId = (int) ($originalEnrollment['parent_id'] ?? 0);
|
|
if ($parentId <= 0) {
|
|
$student = $this->db->table('students')->select('parent_id')->where('id', $studentId)->limit(1)->get()->getRowArray();
|
|
$parentId = (int) ($student['parent_id'] ?? 0);
|
|
}
|
|
if ($parentId > 0) {
|
|
try {
|
|
(new InvoiceController())->generateInvoice(
|
|
(string) $parentId,
|
|
$schoolYear,
|
|
(string) ($originalEnrollment['semester'] ?? getSemester())
|
|
);
|
|
} catch (\Throwable $e) {
|
|
log_message('error', 'Invoice refresh after enrollment admin class assignment failed for parent {parent_id}: {error}', [
|
|
'parent_id' => $parentId,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
private function updateEnrollmentPlacementStatus(int $studentId, string $schoolYear, string $placementStatus): void
|
|
{
|
|
$this->db->table('enrollments')
|
|
->where('student_id', $studentId)
|
|
->where('school_year', $schoolYear)
|
|
->update([
|
|
'placement_status' => $placementStatus,
|
|
'updated_at' => date('Y-m-d H:i:s'),
|
|
]);
|
|
}
|
|
|
|
private function latestEnrollment(int $studentId, string $schoolYear): ?array
|
|
{
|
|
return service('enrollmentStatus')->controllingEnrollment($studentId, $schoolYear);
|
|
}
|
|
|
|
private function audit(int $studentId, string $schoolYear, string $sourceSchoolYear, string $action, ?array $original, array $new, string $reason): void
|
|
{
|
|
if (! $this->db->tableExists('enrollment_transition_audits')) {
|
|
return;
|
|
}
|
|
|
|
$this->db->table('enrollment_transition_audits')->insert([
|
|
'student_id' => $studentId,
|
|
'school_year' => $schoolYear,
|
|
'source_school_year' => $sourceSchoolYear !== '' ? $sourceSchoolYear : null,
|
|
'action' => $action,
|
|
'performed_by' => $this->userId(),
|
|
'original_values_json' => $original !== null ? json_encode($original, JSON_UNESCAPED_SLASHES) : null,
|
|
'new_values_json' => json_encode($new, JSON_UNESCAPED_SLASHES),
|
|
'reason' => $reason,
|
|
'created_at' => date('Y-m-d H:i:s'),
|
|
]);
|
|
}
|
|
|
|
private function auditRows(string $schoolYear): array
|
|
{
|
|
if (! $this->db->tableExists('enrollment_transition_audits')) {
|
|
return [];
|
|
}
|
|
|
|
$builder = $this->db->table('enrollment_transition_audits eta')
|
|
->select('eta.*')
|
|
->select('s.firstname, s.lastname')
|
|
->select('u.firstname AS user_firstname, u.lastname AS user_lastname')
|
|
->join('students s', 's.id = eta.student_id', 'left')
|
|
->join('users u', 'u.id = eta.performed_by', 'left')
|
|
->orderBy('eta.created_at', 'DESC')
|
|
->limit(50);
|
|
|
|
if ($schoolYear !== '') {
|
|
$builder->where('eta.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['performed_by_name'] = trim((string) ($row['user_firstname'] ?? '') . ' ' . (string) ($row['user_lastname'] ?? '')) ?: ((int) ($row['performed_by'] ?? 0) > 0 ? 'User #' . (int) $row['performed_by'] : '');
|
|
}
|
|
unset($row);
|
|
|
|
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'],
|
|
'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')) {
|
|
return ['approved' => false, 'approved_at' => null, 'missing' => ['school year']];
|
|
}
|
|
|
|
$row = $this->db->table('school_years')->where('name', $schoolYear)->limit(1)->get()->getRowArray();
|
|
$this->launchConfigurationComplete($schoolYear, $missing);
|
|
|
|
return [
|
|
'approved' => ! empty($row['registration_launch_approved_at'] ?? null),
|
|
'approved_at' => $row['registration_launch_approved_at'] ?? null,
|
|
'missing' => $missing,
|
|
];
|
|
}
|
|
|
|
private function launchConfigurationComplete(string $schoolYear, ?array &$missing = null): bool
|
|
{
|
|
$missing = [];
|
|
$row = $this->db->table('school_years')->where('name', $schoolYear)->limit(1)->get()->getRowArray();
|
|
if ($row === null) {
|
|
$missing[] = 'school year record';
|
|
return false;
|
|
}
|
|
|
|
foreach (['registration_starts_on' => 'registration opening date', 'registration_ends_on' => 'registration deadline'] as $field => $label) {
|
|
if (empty($row[$field])) {
|
|
$missing[] = $label;
|
|
}
|
|
}
|
|
|
|
if (! $this->db->tableExists('email_templates')) {
|
|
$missing[] = 'email templates table';
|
|
} else {
|
|
$fields = $this->db->getFieldNames('email_templates');
|
|
$keyField = in_array('code', $fields, true) ? 'code' : 'template_key';
|
|
$template = $this->db->table('email_templates')
|
|
->where($keyField, 'registration_opening')
|
|
->where('is_active', 1)
|
|
->countAllResults();
|
|
if ($template <= 0) {
|
|
$missing[] = 'approved registration email template';
|
|
}
|
|
}
|
|
|
|
return $missing === [];
|
|
}
|
|
|
|
private function firstParentWithStudents(): ?int
|
|
{
|
|
if (! $this->db->tableExists('students')) {
|
|
return null;
|
|
}
|
|
|
|
$row = $this->db->table('students')
|
|
->select('parent_id')
|
|
->where('parent_id IS NOT NULL', null, false)
|
|
->orderBy('parent_id', 'ASC')
|
|
->limit(1)
|
|
->get()
|
|
->getRowArray();
|
|
|
|
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')
|
|
->whereNotIn('flag_type', ['CLASS_CAPACITY_EXCEPTION_REQUIRED', 'CLASS_REASSIGNMENT_REQUIRED']);
|
|
if ($schoolYear !== '') {
|
|
$builder->where('school_year', $schoolYear);
|
|
}
|
|
|
|
return $builder->countAllResults();
|
|
}
|
|
|
|
private function flagTypes(): array
|
|
{
|
|
if (! $this->db->tableExists('enrollment_flags')) {
|
|
return [];
|
|
}
|
|
|
|
$types = array_column(
|
|
$this->db->table('enrollment_flags')
|
|
->select('flag_type')
|
|
->whereNotIn('flag_type', ['CLASS_CAPACITY_EXCEPTION_REQUIRED', 'CLASS_REASSIGNMENT_REQUIRED'])
|
|
->distinct()
|
|
->orderBy('flag_type')
|
|
->get()
|
|
->getResultArray(),
|
|
'flag_type'
|
|
);
|
|
|
|
return array_values(array_filter(
|
|
$types,
|
|
static fn ($type): bool => ! in_array((string) $type, ['CLASS_CAPACITY_EXCEPTION_REQUIRED', 'CLASS_REASSIGNMENT_REQUIRED'], true)
|
|
));
|
|
}
|
|
|
|
private function schoolYears(): array
|
|
{
|
|
if (! $this->db->tableExists('school_years')) {
|
|
return [];
|
|
}
|
|
|
|
return $this->db->table('school_years')->select('name')->orderBy('name', 'DESC')->get()->getResultArray();
|
|
}
|
|
|
|
private function classSections(string $schoolYear): array
|
|
{
|
|
if (! $this->db->tableExists('classSection')) {
|
|
return [];
|
|
}
|
|
|
|
$builder = $this->db->table('classSection')->select('class_section_id, class_section_name')->orderBy('class_section_name', 'ASC');
|
|
if ($schoolYear !== '' && $this->db->fieldExists('school_year', 'classSection')) {
|
|
$builder->where('school_year', $schoolYear);
|
|
}
|
|
|
|
return $builder->get()->getResultArray();
|
|
}
|
|
|
|
private function adminUsers(): array
|
|
{
|
|
if (! $this->db->tableExists('users')) {
|
|
return [];
|
|
}
|
|
|
|
return $this->db->table('users')
|
|
->select('id, firstname, lastname, user_type')
|
|
->whereIn('user_type', ['administrator', 'admin', 'principal', 'administrative staff'])
|
|
->orderBy('lastname', 'ASC')
|
|
->get()
|
|
->getResultArray();
|
|
}
|
|
|
|
private function userId(): ?int
|
|
{
|
|
$id = session('user_id') ?? session('id');
|
|
return is_numeric($id) ? (int) $id : null;
|
|
}
|
|
}
|