diff --git a/app/Commands/EnrollmentCloseoutReport.php b/app/Commands/EnrollmentCloseoutReport.php new file mode 100644 index 0000000..e4445d2 --- /dev/null +++ b/app/Commands/EnrollmentCloseoutReport.php @@ -0,0 +1,323 @@ + 'Target school year name. Defaults to the active/current configured year.', + '--json' => 'Print machine-readable JSON.', + '--export' => 'Write closeout exception rows to a CSV file.', + ]; + + private BaseConnection $db; + + public function run(array $params) + { + $this->db = \Config\Database::connect(); + $schoolYear = trim((string) (CLI::getOption('school-year') ?? '')); + if ($schoolYear === '') { + $schoolYear = $this->currentSchoolYear(); + } + + $report = $this->buildReport($schoolYear); + $exportPath = $this->optionValue('export'); + if ($exportPath !== '') { + $this->writeCsv($exportPath, $report['exceptions']); + $report['export_path'] = $exportPath; + } + + if (CLI::getOption('json') !== null) { + CLI::write(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + return; + } + + $this->printReport($report); + } + + private function buildReport(string $schoolYear): array + { + $previousYear = $this->previousSchoolYearName($schoolYear); + $expected = $this->expectedReturningStudents($previousYear); + $enrolled = $this->enrolledStudents($schoolYear); + $unsubmitted = $this->unsubmittedReturningStudents($schoolYear, $previousYear); + $pendingEnrollments = $this->pendingEnrollments($schoolYear); + $unresolvedFlags = $this->unresolvedFlags($schoolYear); + $failedEmails = $this->failedEmails($schoolYear); + + $exceptions = array_merge( + $this->exceptionRows('unsubmitted_returning_student', $unsubmitted), + $this->exceptionRows('pending_enrollment', $pendingEnrollments), + $this->exceptionRows('unresolved_flag', $unresolvedFlags), + $this->exceptionRows('failed_email', $failedEmails) + ); + + $summary = [ + 'expected_returning_students' => count($expected), + 'students_with_target_year_enrollment' => count($enrolled), + 'unsubmitted_returning_students' => count($unsubmitted), + 'pending_enrollments' => count($pendingEnrollments), + 'unresolved_flags' => count($unresolvedFlags), + 'failed_emails' => count($failedEmails), + 'closeout_exception_total' => count($exceptions), + ]; + + return [ + 'school_year' => $schoolYear, + 'source_school_year' => $previousYear, + 'generated_at' => date('Y-m-d H:i:s'), + 'ready_to_close' => $summary['closeout_exception_total'] === 0, + 'summary' => $summary, + 'exceptions' => $exceptions, + ]; + } + + private function expectedReturningStudents(?string $previousYear): array + { + if ($previousYear === null || ! $this->db->tableExists('student_class')) { + return []; + } + + return $this->db->table('student_class sc') + ->select('DISTINCT sc.student_id', false) + ->where('sc.school_year', $previousYear) + ->get() + ->getResultArray(); + } + + private function enrolledStudents(string $schoolYear): array + { + if (! $this->db->tableExists('enrollments')) { + return []; + } + + return $this->db->table('enrollments') + ->select('DISTINCT student_id', false) + ->where('school_year', $schoolYear) + ->get() + ->getResultArray(); + } + + private function unsubmittedReturningStudents(string $schoolYear, ?string $previousYear): array + { + if ($previousYear === null || ! $this->db->tableExists('student_class') || ! $this->db->tableExists('enrollments')) { + return []; + } + + $join = 'e.student_id = sc.student_id AND e.school_year = ' . $this->db->escape($schoolYear); + + return $this->db->table('student_class sc') + ->select('sc.student_id, s.firstname, s.lastname, s.school_id') + ->select('e.id AS enrollment_id, e.enrollment_status, e.admission_status, e.registration_submitted_at') + ->join('students s', 's.id = sc.student_id', 'left') + ->join('enrollments e', $join, 'left', false) + ->where('sc.school_year', $previousYear) + ->groupStart() + ->where('e.id IS NULL') + ->orWhere('e.registration_submitted_at IS NULL', null, false) + ->groupEnd() + ->groupBy('sc.student_id, s.firstname, s.lastname, s.school_id, e.id, e.enrollment_status, e.admission_status, e.registration_submitted_at') + ->orderBy('s.lastname', 'ASC') + ->orderBy('s.firstname', 'ASC') + ->get() + ->getResultArray(); + } + + private function pendingEnrollments(string $schoolYear): array + { + if (! $this->db->tableExists('enrollments')) { + return []; + } + + return $this->db->table('enrollments e') + ->select('e.student_id, e.id AS enrollment_id, e.enrollment_status, e.admission_status, e.registration_submitted_at, e.registration_confirmed_at') + ->select('s.firstname, s.lastname, s.school_id') + ->join('students s', 's.id = e.student_id', 'left') + ->where('e.school_year', $schoolYear) + ->groupStart() + ->where('e.enrollment_status', 'admission under review') + ->orWhere('e.admission_status', 'pending') + ->orWhere('e.registration_confirmed_at IS NULL', null, false) + ->groupEnd() + ->orderBy('s.lastname', 'ASC') + ->orderBy('s.firstname', 'ASC') + ->get() + ->getResultArray(); + } + + private function unresolvedFlags(string $schoolYear): array + { + if (! $this->db->tableExists('enrollment_flags')) { + return []; + } + + return $this->db->table('enrollment_flags ef') + ->select('ef.id AS flag_id, ef.student_id, ef.flag_type, ef.priority, ef.created_at') + ->select('s.firstname, s.lastname, s.school_id') + ->join('students s', 's.id = ef.student_id', 'left') + ->where('ef.school_year', $schoolYear) + ->where('ef.status', 'open') + ->orderBy('ef.priority', 'DESC') + ->orderBy('ef.created_at', 'ASC') + ->get() + ->getResultArray(); + } + + private function failedEmails(string $schoolYear): array + { + if (! $this->db->tableExists('enrollment_email_records')) { + return []; + } + + return $this->db->table('enrollment_email_records') + ->select('id AS email_record_id, parent_user_id, recipient_addresses_json, delivery_status, failure_reason, retry_count, updated_at') + ->where('school_year', $schoolYear) + ->where('delivery_status', 'failed') + ->orderBy('updated_at', 'DESC') + ->get() + ->getResultArray(); + } + + private function exceptionRows(string $type, array $rows): array + { + $exceptions = []; + foreach ($rows as $row) { + $studentName = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')); + $exceptions[] = [ + 'type' => $type, + 'student_id' => $row['student_id'] ?? '', + 'student_name' => $studentName, + 'school_id' => $row['school_id'] ?? '', + 'reference_id' => $row['enrollment_id'] ?? $row['flag_id'] ?? $row['email_record_id'] ?? '', + 'status' => $row['enrollment_status'] ?? $row['flag_type'] ?? $row['delivery_status'] ?? '', + 'detail' => $this->exceptionDetail($type, $row), + ]; + } + + return $exceptions; + } + + private function exceptionDetail(string $type, array $row): string + { + return match ($type) { + 'unsubmitted_returning_student' => empty($row['enrollment_id']) + ? 'No target-year enrollment exists.' + : 'Target-year enrollment exists but registration has not been submitted.', + 'pending_enrollment' => 'Enrollment status: ' . (string) ($row['enrollment_status'] ?? '') . '; admission status: ' . (string) ($row['admission_status'] ?? ''), + 'unresolved_flag' => 'Open flag priority: ' . (string) ($row['priority'] ?? ''), + 'failed_email' => 'Retry count: ' . (int) ($row['retry_count'] ?? 0) . '; reason: ' . (string) ($row['failure_reason'] ?? ''), + default => '', + }; + } + + private function writeCsv(string $path, array $rows): void + { + $directory = dirname($path); + if ($directory !== '' && $directory !== '.' && ! is_dir($directory)) { + throw new \RuntimeException('Export directory does not exist: ' . $directory); + } + + $handle = fopen($path, 'wb'); + if ($handle === false) { + throw new \RuntimeException('Unable to write export file: ' . $path); + } + + fputcsv($handle, ['type', 'student_id', 'student_name', 'school_id', 'reference_id', 'status', 'detail']); + foreach ($rows as $row) { + fputcsv($handle, [ + $row['type'] ?? '', + $row['student_id'] ?? '', + $row['student_name'] ?? '', + $row['school_id'] ?? '', + $row['reference_id'] ?? '', + $row['status'] ?? '', + $row['detail'] ?? '', + ]); + } + + fclose($handle); + } + + private function optionValue(string $name): string + { + $value = CLI::getOption($name); + if (is_string($value) && trim($value) !== '') { + return trim($value); + } + + $argv = $_SERVER['argv'] ?? []; + $long = '--' . $name; + foreach ($argv as $index => $arg) { + if (str_starts_with((string) $arg, $long . '=')) { + return trim(substr((string) $arg, strlen($long) + 1)); + } + if ($arg === $long && isset($argv[$index + 1]) && ! str_starts_with((string) $argv[$index + 1], '--')) { + return trim((string) $argv[$index + 1]); + } + } + + return ''; + } + + private function currentSchoolYear(): string + { + if ($this->db->tableExists('school_years')) { + $row = $this->db->table('school_years') + ->select('name') + ->where('status', 'active') + ->orderBy('id', 'DESC') + ->limit(1) + ->get() + ->getRowArray(); + if (! empty($row['name'])) { + return (string) $row['name']; + } + } + + if ($this->db->tableExists('configuration')) { + $row = $this->db->table('configuration') + ->select('config_value') + ->where('config_key', 'school_year') + ->limit(1) + ->get() + ->getRowArray(); + if (! empty($row['config_value'])) { + return (string) $row['config_value']; + } + } + + return ''; + } + + private function previousSchoolYearName(string $schoolYear): ?string + { + return preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches) + ? ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1) + : null; + } + + private function printReport(array $report): void + { + CLI::write('Registration Closeout Report: ' . (string) ($report['school_year'] ?? ''), ($report['ready_to_close'] ?? false) ? 'green' : 'yellow'); + CLI::write('Generated at: ' . (string) ($report['generated_at'] ?? '')); + CLI::write('Ready to close: ' . (($report['ready_to_close'] ?? false) ? 'yes' : 'no')); + CLI::newLine(); + + foreach (($report['summary'] ?? []) as $key => $value) { + CLI::write($key . ': ' . $value); + } + + if (! empty($report['export_path'])) { + CLI::newLine(); + CLI::write('Export written: ' . (string) $report['export_path'], 'green'); + } + } +} diff --git a/app/Commands/EnrollmentPostLaunchMonitor.php b/app/Commands/EnrollmentPostLaunchMonitor.php new file mode 100644 index 0000000..d07bbcb --- /dev/null +++ b/app/Commands/EnrollmentPostLaunchMonitor.php @@ -0,0 +1,295 @@ + 'Target school year name. Defaults to the active/current configured year.', + '--days' => 'Recent activity window in days. Defaults to 7.', + '--json' => 'Print machine-readable JSON.', + ]; + + private BaseConnection $db; + + public function run(array $params) + { + $this->db = \Config\Database::connect(); + $schoolYear = trim((string) (CLI::getOption('school-year') ?? '')); + if ($schoolYear === '') { + $schoolYear = $this->currentSchoolYear(); + } + + $days = max(1, (int) (CLI::getOption('days') ?? 7)); + $report = $this->buildReport($schoolYear, $days); + + if (CLI::getOption('json') !== null) { + CLI::write(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + return; + } + + $this->printReport($report); + } + + private function buildReport(string $schoolYear, int $days): array + { + $previousYear = $this->previousSchoolYearName($schoolYear); + $expected = $this->expectedReturningStudentCount($previousYear); + $enrollments = $this->enrollmentSummary($schoolYear); + $flags = $this->flagSummary($schoolYear, $days); + $emails = $this->emailSummary($schoolYear); + $audits = $this->auditSummary($schoolYear, $days); + + $submitted = (int) ($enrollments['submitted'] ?? 0); + $progressPercent = $expected > 0 ? round(($submitted / $expected) * 100, 1) : null; + $needsAttention = (int) ($flags['open_total'] ?? 0) + + (int) ($flags['stale_total'] ?? 0) + + (int) ($emails['failed'] ?? 0) + + (int) ($enrollments['blocked_total'] ?? 0); + + return [ + 'school_year' => $schoolYear, + 'source_school_year' => $previousYear, + 'generated_at' => date('Y-m-d H:i:s'), + 'activity_window_days' => $days, + 'expected_returning_students' => $expected, + 'submitted_registrations' => $submitted, + 'progress_percent' => $progressPercent, + 'needs_attention_count' => $needsAttention, + 'enrollments' => $enrollments, + 'flags' => $flags, + 'emails' => $emails, + 'audits' => $audits, + ]; + } + + private function expectedReturningStudentCount(?string $previousYear): int + { + if ($previousYear === null || ! $this->db->tableExists('student_class')) { + return 0; + } + + return (int) ($this->db->table('student_class') + ->select('COUNT(DISTINCT student_id) AS total', false) + ->where('school_year', $previousYear) + ->get() + ->getRowArray()['total'] ?? 0); + } + + private function enrollmentSummary(string $schoolYear): array + { + if (! $this->db->tableExists('enrollments')) { + return [ + 'total' => 0, + 'submitted' => 0, + 'confirmed' => 0, + 'blocked_total' => 0, + 'by_status' => [], + 'by_placement_status' => [], + ]; + } + + $total = $this->countRows('enrollments', ['school_year' => $schoolYear]); + $submitted = $this->db->fieldExists('registration_submitted_at', 'enrollments') + ? $this->countRows('enrollments', ['school_year' => $schoolYear], 'registration_submitted_at IS NOT NULL') + : $total; + $confirmed = $this->db->fieldExists('registration_confirmed_at', 'enrollments') + ? $this->countRows('enrollments', ['school_year' => $schoolYear], 'registration_confirmed_at IS NOT NULL') + : $this->countRows('enrollments', ['school_year' => $schoolYear, 'enrollment_status' => 'enrolled']); + + $blocked = 0; + if ($this->db->fieldExists('parent_enrollment_allowed', 'enrollments')) { + $blocked += $this->countRows('enrollments', ['school_year' => $schoolYear, 'parent_enrollment_allowed' => 0]); + } + if ($this->db->fieldExists('exception_required', 'enrollments')) { + $blocked += $this->countRows('enrollments', ['school_year' => $schoolYear, 'exception_required' => 1]); + } + + return [ + 'total' => $total, + 'submitted' => $submitted, + 'confirmed' => $confirmed, + 'blocked_total' => $blocked, + 'by_status' => $this->groupCount('enrollments', 'enrollment_status', ['school_year' => $schoolYear]), + 'by_placement_status' => $this->db->fieldExists('placement_status', 'enrollments') + ? $this->groupCount('enrollments', 'placement_status', ['school_year' => $schoolYear]) + : [], + ]; + } + + private function flagSummary(string $schoolYear, int $days): array + { + if (! $this->db->tableExists('enrollment_flags')) { + return [ + 'open_total' => 0, + 'stale_total' => 0, + 'by_type' => [], + 'by_priority' => [], + ]; + } + + $staleBefore = date('Y-m-d H:i:s', strtotime('-' . $days . ' days')); + + return [ + 'open_total' => $this->countRows('enrollment_flags', ['school_year' => $schoolYear, 'status' => 'open']), + 'stale_total' => $this->countRows('enrollment_flags', ['school_year' => $schoolYear, 'status' => 'open'], 'created_at < ' . $this->db->escape($staleBefore)), + 'by_type' => $this->groupCount('enrollment_flags', 'flag_type', ['school_year' => $schoolYear, 'status' => 'open']), + 'by_priority' => $this->groupCount('enrollment_flags', 'priority', ['school_year' => $schoolYear, 'status' => 'open']), + ]; + } + + private function emailSummary(string $schoolYear): array + { + if (! $this->db->tableExists('enrollment_email_records')) { + return [ + 'total' => 0, + 'sent' => 0, + 'failed' => 0, + 'pending' => 0, + 'by_status' => [], + ]; + } + + return [ + 'total' => $this->countRows('enrollment_email_records', ['school_year' => $schoolYear]), + 'sent' => $this->countRows('enrollment_email_records', ['school_year' => $schoolYear, 'delivery_status' => 'sent']), + 'failed' => $this->countRows('enrollment_email_records', ['school_year' => $schoolYear, 'delivery_status' => 'failed']), + 'pending' => $this->countRows('enrollment_email_records', ['school_year' => $schoolYear, 'delivery_status' => 'pending']), + 'by_status' => $this->groupCount('enrollment_email_records', 'delivery_status', ['school_year' => $schoolYear]), + ]; + } + + private function auditSummary(string $schoolYear, int $days): array + { + if (! $this->db->tableExists('enrollment_transition_audits')) { + return [ + 'recent_total' => 0, + 'by_action' => [], + ]; + } + + $since = date('Y-m-d H:i:s', strtotime('-' . $days . ' days')); + + return [ + 'recent_total' => $this->countRows('enrollment_transition_audits', ['school_year' => $schoolYear], 'created_at >= ' . $this->db->escape($since)), + 'by_action' => $this->groupCount('enrollment_transition_audits', 'action', ['school_year' => $schoolYear], 'created_at >= ' . $this->db->escape($since)), + ]; + } + + private function countRows(string $table, array $where, ?string $rawWhere = null): int + { + $builder = $this->db->table($table); + foreach ($where as $field => $value) { + $builder->where($field, $value); + } + if ($rawWhere !== null) { + $builder->where($rawWhere, null, false); + } + + return $builder->countAllResults(); + } + + private function groupCount(string $table, string $field, array $where, ?string $rawWhere = null): array + { + if (! $this->db->fieldExists($field, $table)) { + return []; + } + + $builder = $this->db->table($table) + ->select($field . ' AS value, COUNT(*) AS total', false) + ->groupBy($field) + ->orderBy('total', 'DESC', false); + + foreach ($where as $whereField => $value) { + $builder->where($whereField, $value); + } + if ($rawWhere !== null) { + $builder->where($rawWhere, null, false); + } + + $result = []; + foreach ($builder->get()->getResultArray() as $row) { + $key = trim((string) ($row['value'] ?? '')) ?: 'blank'; + $result[$key] = (int) ($row['total'] ?? 0); + } + + return $result; + } + + private function currentSchoolYear(): string + { + if ($this->db->tableExists('school_years')) { + $row = $this->db->table('school_years') + ->select('name') + ->where('status', 'active') + ->orderBy('id', 'DESC') + ->limit(1) + ->get() + ->getRowArray(); + if (! empty($row['name'])) { + return (string) $row['name']; + } + } + + if ($this->db->tableExists('configuration')) { + $row = $this->db->table('configuration') + ->select('config_value') + ->where('config_key', 'school_year') + ->limit(1) + ->get() + ->getRowArray(); + if (! empty($row['config_value'])) { + return (string) $row['config_value']; + } + } + + return ''; + } + + private function previousSchoolYearName(string $schoolYear): ?string + { + return preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches) + ? ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1) + : null; + } + + private function printReport(array $report): void + { + CLI::write('Registration Post-Launch Monitor: ' . (string) ($report['school_year'] ?? ''), 'cyan'); + CLI::write('Generated at: ' . (string) ($report['generated_at'] ?? '')); + CLI::write('Expected returning students: ' . (int) ($report['expected_returning_students'] ?? 0)); + CLI::write('Submitted registrations: ' . (int) ($report['submitted_registrations'] ?? 0)); + CLI::write('Progress: ' . (($report['progress_percent'] ?? null) === null ? 'n/a' : (string) $report['progress_percent'] . '%')); + CLI::write('Needs attention: ' . (int) ($report['needs_attention_count'] ?? 0), ((int) ($report['needs_attention_count'] ?? 0)) > 0 ? 'yellow' : 'green'); + CLI::newLine(); + + $this->printSection('Enrollments', $report['enrollments'] ?? []); + $this->printSection('Flags', $report['flags'] ?? []); + $this->printSection('Emails', $report['emails'] ?? []); + $this->printSection('Recent Audits', $report['audits'] ?? []); + } + + private function printSection(string $title, array $data): void + { + CLI::write($title, 'white'); + foreach ($data as $key => $value) { + if (is_array($value)) { + CLI::write(' ' . $key . ':'); + foreach ($value as $nestedKey => $nestedValue) { + CLI::write(' ' . $nestedKey . ': ' . $nestedValue); + } + } else { + CLI::write(' ' . $key . ': ' . $value); + } + } + CLI::newLine(); + } +} diff --git a/app/Commands/EnrollmentReleaseAudit.php b/app/Commands/EnrollmentReleaseAudit.php new file mode 100644 index 0000000..839ef0d --- /dev/null +++ b/app/Commands/EnrollmentReleaseAudit.php @@ -0,0 +1,319 @@ + 'Target school year name. Defaults to the active/current configured year.', + '--json' => 'Print machine-readable JSON.', + ]; + + private BaseConnection $db; + + public function run(array $params) + { + $this->db = \Config\Database::connect(); + $schoolYear = trim((string) (CLI::getOption('school-year') ?? '')); + if ($schoolYear === '') { + $schoolYear = $this->currentSchoolYear(); + } + + $report = $this->buildReport($schoolYear); + + if (CLI::getOption('json') !== null) { + CLI::write(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + return; + } + + $this->printReport($report); + } + + private function buildReport(string $schoolYear): array + { + $previousYear = $this->previousSchoolYearName($schoolYear); + $checks = [ + 'school_year_configuration' => $this->schoolYearConfigurationCheck($schoolYear), + 'launch_approval' => $this->launchApprovalCheck($schoolYear), + 'deliberation_decisions' => $this->deliberationDecisionCheck($schoolYear, $previousYear), + 'open_enrollment_flags' => $this->openEnrollmentFlagsCheck($schoolYear), + 'failed_registration_emails' => $this->failedEmailCheck($schoolYear), + 'duplicate_or_invalid_contacts' => $this->contactQualityCheck(), + 'email_preview_sample' => $this->emailPreviewCheck($schoolYear), + ]; + + $blocking = 0; + $warnings = 0; + foreach ($checks as $check) { + if (($check['severity'] ?? '') === 'blocking') { + $blocking++; + } elseif (($check['severity'] ?? '') === 'warning') { + $warnings++; + } + } + + return [ + 'school_year' => $schoolYear, + 'source_school_year' => $previousYear, + 'generated_at' => date('Y-m-d H:i:s'), + 'ready' => $blocking === 0, + 'blocking_count' => $blocking, + 'warning_count' => $warnings, + 'checks' => $checks, + ]; + } + + private function schoolYearConfigurationCheck(string $schoolYear): array + { + if (! $this->db->tableExists('school_years')) { + return $this->check('blocking', 'school_years table is missing.'); + } + + $row = $this->db->table('school_years')->where('name', $schoolYear)->limit(1)->get()->getRowArray(); + if ($row === null) { + return $this->check('blocking', 'Target school year record was not found.'); + } + + $missing = []; + 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'; + } elseif ($this->activeTemplateCount('registration_opening') <= 0) { + $missing[] = 'active registration email template'; + } + + return $missing === [] + ? $this->check('pass', 'Required school-year launch configuration is present.') + : $this->check('blocking', 'Missing: ' . implode(', ', $missing)); + } + + private function launchApprovalCheck(string $schoolYear): array + { + if (! $this->db->tableExists('school_years') || ! $this->db->fieldExists('registration_launch_approved_at', 'school_years')) { + return $this->check('warning', 'Launch approval fields have not been migrated yet.'); + } + + $row = $this->db->table('school_years') + ->select('registration_launch_approved_at') + ->where('name', $schoolYear) + ->limit(1) + ->get() + ->getRowArray(); + + return ! empty($row['registration_launch_approved_at'] ?? null) + ? $this->check('pass', 'Registration launch has been approved.') + : $this->check('warning', 'Registration launch has not been approved yet.'); + } + + private function deliberationDecisionCheck(string $schoolYear, ?string $previousYear): array + { + if ($previousYear === null) { + return $this->check('warning', 'Previous school year could not be inferred.'); + } + if (! $this->db->tableExists('student_class') || ! $this->db->tableExists('student_decisions')) { + return $this->check('warning', 'Student placement or decision tables are missing.'); + } + + $rows = $this->db->table('student_class sc') + ->select('COUNT(DISTINCT sc.student_id) AS total', false) + ->join('student_decisions sd', 'sd.student_id = sc.student_id AND sd.school_year = ' . $this->db->escape($previousYear), 'left', false) + ->where('sc.school_year', $previousYear) + ->groupStart() + ->where('sd.id IS NULL') + ->orWhere('sd.decision IS NULL') + ->orWhere('TRIM(sd.decision) =', '') + ->groupEnd() + ->get() + ->getRowArray(); + + $missing = (int) ($rows['total'] ?? 0); + return $missing === 0 + ? $this->check('pass', 'All placed source-year students have a recorded deliberation decision.') + : $this->check('blocking', $missing . ' source-year student(s) are missing deliberation decisions.'); + } + + private function openEnrollmentFlagsCheck(string $schoolYear): array + { + if (! $this->db->tableExists('enrollment_flags')) { + return $this->check('warning', 'Enrollment flags table has not been migrated yet.'); + } + + $count = $this->db->table('enrollment_flags') + ->where('school_year', $schoolYear) + ->where('status', 'open') + ->countAllResults(); + + return $count === 0 + ? $this->check('pass', 'No open enrollment follow-up flags remain.') + : $this->check('warning', $count . ' open enrollment follow-up flag(s) remain.'); + } + + private function failedEmailCheck(string $schoolYear): array + { + if (! $this->db->tableExists('enrollment_email_records')) { + return $this->check('warning', 'Enrollment email record table has not been migrated yet.'); + } + + $failed = $this->db->table('enrollment_email_records') + ->where('school_year', $schoolYear) + ->where('delivery_status', 'failed') + ->countAllResults(); + + return $failed === 0 + ? $this->check('pass', 'No failed registration email records found.') + : $this->check('warning', $failed . ' failed registration email record(s) need retry or review.'); + } + + private function contactQualityCheck(): array + { + if (! $this->db->tableExists('users')) { + return $this->check('warning', 'Users table is missing.'); + } + if (! $this->db->fieldExists('email', 'users')) { + return $this->check('warning', 'Users table does not include an email field.'); + } + + $rows = $this->db->query( + "SELECT LOWER(TRIM(email)) AS normalized_email, COUNT(*) AS total + FROM users + WHERE email IS NOT NULL AND email <> '' + GROUP BY LOWER(TRIM(email)) + HAVING COUNT(*) > 1" + )->getResultArray(); + + $invalid = 0; + foreach ($this->db->table('users')->select('email')->where('email IS NOT NULL')->where('email !=', '')->get()->getResultArray() as $row) { + if (! filter_var((string) ($row['email'] ?? ''), FILTER_VALIDATE_EMAIL)) { + $invalid++; + } + } + + $duplicates = count($rows); + if ($duplicates === 0 && $invalid === 0) { + return $this->check('pass', 'No duplicate or invalid parent/user email addresses were detected.'); + } + + return $this->check('warning', $duplicates . ' duplicate email value(s) and ' . $invalid . ' invalid email address(es) were detected.'); + } + + private function emailPreviewCheck(string $schoolYear): array + { + if (! $this->db->tableExists('students')) { + return $this->check('warning', 'Students table is missing.'); + } + + $row = $this->db->table('students') + ->select('parent_id') + ->where('parent_id IS NOT NULL', null, false) + ->orderBy('parent_id', 'ASC') + ->limit(1) + ->get() + ->getRowArray(); + + $parentId = is_numeric($row['parent_id'] ?? null) ? (int) $row['parent_id'] : 0; + if ($parentId <= 0) { + return $this->check('warning', 'No parent with students was found for email preview.'); + } + + try { + $message = service('enrollmentRegistrationEmail')->previewForParent($schoolYear, $parentId); + } catch (\Throwable $e) { + return $this->check('blocking', 'Email preview generation failed: ' . $e->getMessage()); + } + + return $message !== null && trim((string) ($message['body'] ?? '')) !== '' + ? $this->check('pass', 'A consolidated registration email preview can be generated.') + : $this->check('blocking', 'No consolidated registration email preview could be generated.'); + } + + private function currentSchoolYear(): string + { + if ($this->db->tableExists('school_years')) { + $row = $this->db->table('school_years') + ->select('name') + ->where('status', 'active') + ->orderBy('id', 'DESC') + ->limit(1) + ->get() + ->getRowArray(); + if (! empty($row['name'])) { + return (string) $row['name']; + } + } + + if ($this->db->tableExists('configuration')) { + $row = $this->db->table('configuration') + ->select('config_value') + ->where('config_key', 'school_year') + ->limit(1) + ->get() + ->getRowArray(); + if (! empty($row['config_value'])) { + return (string) $row['config_value']; + } + } + + return ''; + } + + private function activeTemplateCount(string $key): int + { + $fields = $this->db->getFieldNames('email_templates'); + $keyField = in_array('code', $fields, true) ? 'code' : 'template_key'; + + return $this->db->table('email_templates') + ->where($keyField, $key) + ->where('is_active', 1) + ->countAllResults(); + } + + private function previousSchoolYearName(string $schoolYear): ?string + { + return preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches) + ? ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1) + : null; + } + + private function check(string $severity, string $message): array + { + return ['severity' => $severity, 'message' => $message]; + } + + private function printReport(array $report): void + { + CLI::write('Registration Release Audit: ' . (string) ($report['school_year'] ?? ''), ($report['ready'] ?? false) ? 'green' : 'red'); + CLI::write('Generated at: ' . (string) ($report['generated_at'] ?? '')); + CLI::write('Ready: ' . (($report['ready'] ?? false) ? 'yes' : 'no')); + CLI::newLine(); + + foreach (($report['checks'] ?? []) as $name => $check) { + $severity = (string) ($check['severity'] ?? 'warning'); + $color = match ($severity) { + 'pass' => 'green', + 'blocking' => 'red', + default => 'yellow', + }; + CLI::write(sprintf('[%s] %s: %s', strtoupper($severity), $name, (string) ($check['message'] ?? '')), $color); + } + + CLI::newLine(); + CLI::write('Blocking: ' . (int) ($report['blocking_count'] ?? 0), ((int) ($report['blocking_count'] ?? 0)) > 0 ? 'red' : 'green'); + CLI::write('Warnings: ' . (int) ($report['warning_count'] ?? 0), ((int) ($report['warning_count'] ?? 0)) > 0 ? 'yellow' : 'green'); + } +} diff --git a/app/Commands/SendRegistrationOpeningEmail.php b/app/Commands/SendRegistrationOpeningEmail.php index 11f91a1..c4e81ec 100644 --- a/app/Commands/SendRegistrationOpeningEmail.php +++ b/app/Commands/SendRegistrationOpeningEmail.php @@ -2,7 +2,6 @@ namespace App\Commands; -use App\Services\RegistrationOpeningEmailService; use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\CLI; @@ -32,7 +31,7 @@ class SendRegistrationOpeningEmail extends BaseCommand return; } - $service = new RegistrationOpeningEmailService(); + $service = service('enrollmentRegistrationEmail'); $summary = $service->sendForDate($date, $force, $email, $dryRun); foreach ($summary['messages'] as $message) { diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 4b87c04..d845525 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -107,6 +107,14 @@ $routes->post('student/score-card', 'View\StudentController::scoreCard'); $routes->get('student/score-card', 'View\StudentController::scoreCardIndex'); $routes->get('student/score-card/list', 'View\StudentController::scoreCardList'); $routes->get('administrator/student-score-card', 'View\StudentController::scoreCardAdmin'); +$routes->get('administrator/enrollment-admin', 'View\EnrollmentAdminController::dashboard', ['filter' => 'auth:admin']); +$routes->get('administrator/enrollment-admin/email-preview', 'View\EnrollmentAdminController::previewEmail', ['filter' => 'auth:admin']); +$routes->post('administrator/enrollment-admin/approve-launch', 'View\EnrollmentAdminController::approveLaunch', ['filter' => 'auth:admin']); +$routes->post('administrator/enrollment-admin/send-registration-emails', 'View\EnrollmentAdminController::sendRegistrationEmails', ['filter' => 'auth:admin']); +$routes->post('administrator/enrollment-admin/flags/(:num)/resolve', 'View\EnrollmentAdminController::resolveFlag/$1', ['filter' => 'auth:admin']); +$routes->post('administrator/enrollment-admin/flags/(:num)/assign-class', 'View\EnrollmentAdminController::assignClass/$1', ['filter' => 'auth:admin']); +$routes->post('administrator/enrollment-admin/flags/(:num)/makeup-promotion', 'View\EnrollmentAdminController::confirmMakeupPromotion/$1', ['filter' => 'auth:admin']); +$routes->post('administrator/enrollment-admin/flags/(:num)/approve-exception', 'View\EnrollmentAdminController::approveException/$1', ['filter' => 'auth:admin']); // API for report card meta (students, class sections, school years) $routes->get('api/printables/report-card/meta', 'View\ReportCardsController::reportCardMeta', ['filter' => 'auth']); $routes->get('api/printables/report-card/completeness', 'View\ReportCardsController::reportCardCompleteness', ['filter' => 'auth']); @@ -255,6 +263,8 @@ $routes->post('administrator/remove_class_student', 'View\StudentController::rem // Sections auto-distribution (admin) $routes->get('administrator/sections/auto-distribute', 'View\StudentController::autoDistributePage'); $routes->post('administrator/sections/auto-distribute', 'View\StudentController::autoDistributeSections'); +$routes->post('administrator/sections/distribution-draft/update', 'View\StudentController::updateDistributionDraft'); +$routes->post('administrator/sections/distribution-candidate/update', 'View\StudentController::updateDistributionCandidate'); // Totals API for dashboard $routes->get('administrator/sections/promotion-totals', 'View\StudentController::promotionTotalsApi'); @@ -1279,7 +1289,6 @@ $routes->get('flags/flags_management', 'View\FlagController::index'); $routes->get('flags/processed_flags', 'View\FlagController::processedFlags'); $routes->get('flags/incident_analysis', 'View\FlagController::incidentAnalysis'); $routes->post('flags/add', 'View\FlagController::addFlag'); -$routes->get('/flags/update_state/(:num)', 'View\FlagController::updateState/$1'); $routes->post('/flags/update_state/(:num)', 'View\FlagController::updateState/$1'); $routes->get('flags/getStudentsByGrade/(:num)', 'View\FlagController::getStudentsByGrade/$1'); $routes->get('flags/history', 'View\FlagController::history'); diff --git a/app/Config/Services.php b/app/Config/Services.php index eb8acf4..77d9c2b 100644 --- a/app/Config/Services.php +++ b/app/Config/Services.php @@ -279,4 +279,26 @@ class Services extends BaseService \Config\Database::connect() ); } + + public static function enrollmentTransition(bool $getShared = true): \App\Services\EnrollmentTransitionService + { + if ($getShared) { + return static::getSharedInstance('enrollmentTransition'); + } + + return new \App\Services\EnrollmentTransitionService(\Config\Database::connect()); + } + + public static function enrollmentRegistrationEmail(bool $getShared = true): \App\Services\EnrollmentRegistrationEmailService + { + if ($getShared) { + return static::getSharedInstance('enrollmentRegistrationEmail'); + } + + return new \App\Services\EnrollmentRegistrationEmailService( + \Config\Database::connect(), + static::enrollmentTransition(), + static::emailService() + ); + } } diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php index 6f59147..1ada60c 100644 --- a/app/Controllers/BaseController.php +++ b/app/Controllers/BaseController.php @@ -132,6 +132,16 @@ abstract class BaseController extends Controller service('schoolYearWriteGuard')->assertWritable($context, $allowDraftForAdmin, $isAdmin); } + protected function assertSchoolYearNameWritable( + string $schoolYear, + bool $allowDraftForAdmin = false, + bool $isAdmin = false + ): void { + $context = service('schoolYearContext')->forYearName($schoolYear); + + $this->assertSchoolYearWritable($context, $allowDraftForAdmin, $isAdmin); + } + private function syncSchoolYearPropertyFromContext(): void { if (! property_exists($this, 'schoolYear')) { diff --git a/app/Controllers/View/AdministratorController.php b/app/Controllers/View/AdministratorController.php index 42e797a..2f95675 100644 --- a/app/Controllers/View/AdministratorController.php +++ b/app/Controllers/View/AdministratorController.php @@ -33,6 +33,7 @@ use App\Models\TeacherSubmissionNotificationHistoryModel; use App\Models\ExamDraftModel; use App\Models\HomeworkModel; use App\Services\SemesterRangeService; +use App\Support\Enrollment\DeliberationDecision; use CodeIgniter\Events\Events; @@ -572,14 +573,14 @@ class AdministratorController extends BaseController // USERS (phone col: cellphone) $uCols = ['firstname', 'lastname', 'email', 'cellphone', 'school_id', 'city', 'state']; $uQB = $db->table('users') - ->select('id, firstname, lastname, email, cellphone, school_id, city, state, semester'); + ->select('id, firstname, lastname, email, cellphone, school_id, city, state'); $applyMultiTokenLike($uQB, $uCols, $tokens, ['cellphone']); $users = $uQB->limit(150)->get()->getResultArray(); // STUDENTS (no phone column to search) $sCols = ['firstname', 'lastname', 'school_id', 'rfid_tag', 'dob', 'gender']; $sQB = $db->table('students') - ->select('id, parent_id, school_id, firstname, lastname, dob, gender, semester, rfid_tag'); + ->select('id, parent_id, school_id, firstname, lastname, dob, gender, rfid_tag'); $applyMultiTokenLike($sQB, $sCols, $tokens, []); $students = $sQB->limit(150)->get()->getResultArray(); @@ -2061,6 +2062,7 @@ class AdministratorController extends BaseController { $db = db_connect(); $isPg = ($db->getPlatform() === 'Postgre'); // 'MySQLi', 'Postgre', 'SQLSRV', ... + $selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); // In MySQL, avoid truncation of long lists if (!$isPg) { @@ -2133,20 +2135,23 @@ class AdministratorController extends BaseController ->getResultArray(); } - // === Inject class_section_name from student_class via your method and replace grade === + // === Inject current-year class_section_name from student_class and replace grade === foreach ($students as $i => $row) { $sid = (int) ($row['id'] ?? 0); if ($sid > 0) { - // Fetch class_section_name for this student - // Assumes your method signature: getClassSectionNameByStudentId(int $studentId): ?string - $classSectionName = (string) ($this->studentClassModel->getClassSectionNameByStudentId($sid) ?? ''); + $classSectionName = (string) ($this->studentClassModel->getClassSectionNameByStudentId($sid, $selectedYear) ?? ''); - // Expose it explicitly and replace original grade if present $students[$i]['class_section_name'] = $classSectionName; } else { // Keep keys consistent even if id missing $students[$i]['class_section_name'] = ''; } + + $studentYear = trim((string) ($row['school_year'] ?? '')); + $students[$i]['age'] = $this->calculateAgeAsOfSchoolYearStartYear( + $row['dob'] ?? null, + $studentYear !== '' ? $studentYear : $selectedYear + ); } // === end injection === @@ -2154,9 +2159,45 @@ class AdministratorController extends BaseController 'students' => $students, 'gradeOptions' => ['K', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'Youth'], 'genderOptions' => ['Male', 'Female', 'Other'], + 'selectedYear' => $selectedYear, ]); } + private function calculateAgeAsOfSchoolYearStartYear(?string $dob, string $schoolYear): ?int + { + $dob = trim((string) $dob); + $schoolYear = trim($schoolYear); + + if ($dob === '' || ! preg_match('/^(\d{4})/', $schoolYear, $matches)) { + return null; + } + + try { + $timezone = new \DateTimeZone((string) (config('School')->attendance['timezone'] ?? user_timezone())); + $birthDate = \DateTimeImmutable::createFromFormat('!Y-m-d', $dob, $timezone); + $errors = \DateTimeImmutable::getLastErrors(); + $hasParseErrors = is_array($errors) + && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0); + + if ($birthDate === false || $hasParseErrors) { + return null; + } + + $schoolYearStartYearCutoff = new \DateTimeImmutable($matches[1] . '-09-01', $timezone); + if ($birthDate > $schoolYearStartYearCutoff) { + return null; + } + + return $birthDate->diff($schoolYearStartYearCutoff)->y; + } catch (\Throwable $e) { + log_message('warning', 'Unable to calculate school-year age from DOB: {message}', [ + 'message' => $e->getMessage(), + ]); + + return null; + } + } + public function parentProfiles() @@ -2330,28 +2371,19 @@ class AdministratorController extends BaseController $schoolYearContext = $this->resolveSchoolYearContext(); $selectedYear = $schoolYearContext->yearName(); + $this->syncReviewDecisionEnrollments($selectedYear); + $students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear); - $selectedStartYear = $this->getSchoolYearStartYear((string)$selectedYear); - $removedPriorIds = []; - if ($selectedStartYear !== null) { - $removedRows = $this->db->table('enrollments') - ->select('student_id, school_year') - ->where('is_withdrawn', 1) - ->get()->getResultArray(); - foreach ($removedRows as $row) { - $rowYear = $this->getSchoolYearStartYear((string)($row['school_year'] ?? '')); - if ($rowYear !== null && $rowYear < $selectedStartYear) { - $removedPriorIds[(int)($row['student_id'] ?? 0)] = true; - } - } - } + $removedPriorStatuses = $this->removedPriorYearStudentStatuses($selectedYear); $returningStudentIds = $this->priorYearStudentIds($selectedYear); foreach ($students as &$s) { // ===== Ensure IDs needed by the modal ===== $s['student_id'] = (int)($s['id'] ?? 0); - $s['removed_previous_year'] = isset($removedPriorIds[$s['student_id']]) ? 'Yes' : 'No'; + $priorRemovedStatus = $removedPriorStatuses[$s['student_id']] ?? null; + $s['removed_previous_year'] = $priorRemovedStatus !== null ? 'Yes' : 'No'; + $s['prior_removed_status'] = $priorRemovedStatus; // Prefer parent_id; fallback to secondparent_user_id if present if (empty($s['parent_id']) && !empty($s['secondparent_user_id'])) { @@ -2389,7 +2421,9 @@ class AdministratorController extends BaseController // ===== Admission override ===== // Enrollment status for selected year $statusForYear = $this->enrollmentModel->getEnrollmentStatus((int)$s['student_id'], $selectedYear); - if (!empty($statusForYear)) { + if (!empty($priorRemovedStatus)) { + $s['enrollment_status'] = $priorRemovedStatus; + } elseif (!empty($statusForYear)) { $s['enrollment_status'] = $statusForYear; } elseif (($s['admission_status'] ?? null) === 'denied') { $s['enrollment_status'] = 'denied'; @@ -2401,6 +2435,8 @@ class AdministratorController extends BaseController // ===== Class section name for the selected year ===== $name = $this->studentClassModel->getClassSectionsByStudentId((int)$s['student_id'], $selectedYear); $s['class_section'] = $name ?: 'Class not Assigned'; + $calculatedAge = $this->calculateAgeAsOfSchoolYearStartYear($s['dob'] ?? null, $selectedYear); + $s['age'] = $calculatedAge ?? ($s['age'] ?? null); // ===== Sortable registration date (for data-order in view) ===== $s['registration_date_order'] = !empty($s['registration_date']) @@ -2445,27 +2481,18 @@ class AdministratorController extends BaseController try { $selectedYear = $this->currentSchoolYearName((string)($this->schoolYear ?? '')); + $this->syncReviewDecisionEnrollments($selectedYear); + $students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear); - $selectedStartYear = $this->getSchoolYearStartYear((string)$selectedYear); - $removedPriorIds = []; - if ($selectedStartYear !== null) { - $removedRows = $this->db->table('enrollments') - ->select('student_id, school_year') - ->where('is_withdrawn', 1) - ->get()->getResultArray(); - foreach ($removedRows as $row) { - $rowYear = $this->getSchoolYearStartYear((string)($row['school_year'] ?? '')); - if ($rowYear !== null && $rowYear < $selectedStartYear) { - $removedPriorIds[(int)($row['student_id'] ?? 0)] = true; - } - } - } + $removedPriorStatuses = $this->removedPriorYearStudentStatuses($selectedYear); $returningStudentIds = $this->priorYearStudentIds($selectedYear); foreach ($students as &$s) { $s['student_id'] = (int)($s['id'] ?? 0); - $s['removed_previous_year'] = isset($removedPriorIds[$s['student_id']]) ? 'Yes' : 'No'; + $priorRemovedStatus = $removedPriorStatuses[$s['student_id']] ?? null; + $s['removed_previous_year'] = $priorRemovedStatus !== null ? 'Yes' : 'No'; + $s['prior_removed_status'] = $priorRemovedStatus; if (empty($s['parent_id']) && !empty($s['secondparent_user_id'])) { $s['parent_id'] = (int)$s['secondparent_user_id']; @@ -2490,7 +2517,9 @@ class AdministratorController extends BaseController $s['new_student'] = $s['is_new'] === 1 ? 'Yes' : 'No'; $statusForYear = $this->enrollmentModel->getEnrollmentStatus((int)$s['student_id'], $selectedYear); - if (!empty($statusForYear)) { + if (!empty($priorRemovedStatus)) { + $s['enrollment_status'] = $priorRemovedStatus; + } elseif (!empty($statusForYear)) { $s['enrollment_status'] = $statusForYear; } elseif (($s['admission_status'] ?? null) === 'denied') { $s['enrollment_status'] = 'denied'; @@ -2501,6 +2530,8 @@ class AdministratorController extends BaseController $className = $this->studentClassModel->getClassSectionsByStudentId((int)$s['student_id'], $selectedYear); $s['class_section'] = $className ?: 'Class not Assigned'; + $calculatedAge = $this->calculateAgeAsOfSchoolYearStartYear($s['dob'] ?? null, $selectedYear); + $s['age'] = $calculatedAge ?? ($s['age'] ?? null); $s['registration_date_order'] = !empty($s['registration_date']) ? date('Y-m-d', strtotime($s['registration_date'])) @@ -2536,6 +2567,172 @@ class AdministratorController extends BaseController } } + private function syncReviewDecisionEnrollments(string $selectedYear): void + { + $selectedYear = trim($selectedYear); + $sourceYear = $this->getPreviousSchoolYear($selectedYear); + if ($selectedYear === '' || $sourceYear === '' || ! $this->db->tableExists('enrollments')) { + return; + } + + $studentIds = $this->sourceYearStudentIds($sourceYear); + if ($studentIds === []) { + return; + } + + $transitionService = service('enrollmentTransition'); + $now = utc_now(); + + foreach ($studentIds as $studentId) { + try { + $evaluation = $transitionService->evaluate((int) $studentId, $sourceYear, $selectedYear, 'parent'); + } catch (\Throwable $e) { + log_message('error', 'Review & Decision enrollment sync evaluation failed for student {studentId}: {message}', [ + 'studentId' => $studentId, + 'message' => $e->getMessage(), + ]); + continue; + } + + if (! $this->needsReviewDecisionEnrollment($evaluation)) { + continue; + } + + $student = $this->studentModel->find((int) $studentId); + if (! is_array($student)) { + continue; + } + + $parentId = (int) ($student['parent_id'] ?? ($student['secondparent_user_id'] ?? 0)); + if ($parentId <= 0) { + log_message('warning', 'Review & Decision enrollment sync skipped student {studentId}: no parent ID.', [ + 'studentId' => $studentId, + ]); + continue; + } + + $existing = $this->db->table('enrollments') + ->select('id, enrollment_status') + ->where('student_id', (int) $studentId) + ->where('school_year', $selectedYear) + ->orderBy('updated_at', 'DESC') + ->orderBy('id', 'DESC') + ->limit(1) + ->get() + ->getRowArray(); + + if ($existing !== null) { + $existingStatus = (string) ($existing['enrollment_status'] ?? ''); + if ($existingStatus === 'review & decision' || ! in_array($existingStatus, ['', 'admission under review'], true)) { + continue; + } + } + + $payload = [ + 'student_id' => (int) $studentId, + 'parent_id' => $parentId, + 'school_year' => $selectedYear, + 'semester' => (string) $this->semester, + 'source_school_year' => $sourceYear, + '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, + 'placement_status' => $evaluation['placement_status'] ?? 'not_created', + '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' => 1, + 'exception_reason' => implode(', ', array_filter(array_column($evaluation['flags'] ?? [], 'flag_type'))) ?: implode(' ', array_map('strval', $evaluation['blockers'] ?? [])), + 'enrollment_date' => local_date(utc_now(), 'Y-m-d'), + 'enrollment_status' => 'review & decision', + 'admission_status' => 'pending', + 'is_withdrawn' => 0, + 'updated_at' => $now, + ]; + $payload = $this->filterEnrollmentPayloadByColumns($payload); + + if ($existing !== null) { + $this->db->table('enrollments') + ->where('id', (int) $existing['id']) + ->update($payload); + } else { + $payload['created_at'] = $now; + $this->db->table('enrollments')->insert($this->filterEnrollmentPayloadByColumns($payload)); + } + } + } + + private function needsReviewDecisionEnrollment(array $evaluation): bool + { + $decision = (string) ($evaluation['deliberation_decision'] ?? ''); + + if (in_array($decision, [ + DeliberationDecision::EXPELLED, + DeliberationDecision::WITHDRAWN, + DeliberationDecision::DEFERRED_DECISION, + ], true)) { + return true; + } + + $hasSourceAssignment = (int) ($evaluation['source_class_section_id'] ?? 0) > 0 + || (int) ($evaluation['source_grade_id'] ?? 0) > 0; + + return $decision === '' + && $hasSourceAssignment + && array_filter($evaluation['blockers'] ?? []) !== []; + } + + private function sourceYearStudentIds(string $sourceYear): array + { + $studentIds = []; + + foreach (['student_class', 'enrollments', 'student_decisions'] as $table) { + if (! $this->db->tableExists($table) || ! $this->db->fieldExists('student_id', $table)) { + continue; + } + + $yearColumn = match ($table) { + 'student_class', 'student_decisions' => 'school_year', + default => 'school_year', + }; + + if (! $this->db->fieldExists($yearColumn, $table)) { + continue; + } + + $rows = $this->db->table($table) + ->select('student_id') + ->where($yearColumn, $sourceYear) + ->where('student_id IS NOT NULL', null, false) + ->get() + ->getResultArray(); + + foreach ($rows as $row) { + $studentId = (int) ($row['student_id'] ?? 0); + if ($studentId > 0) { + $studentIds[$studentId] = true; + } + } + } + + return array_keys($studentIds); + } + + 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 enrollmentClassOptions(string $selectedYear): array { $select = ['id', 'class_section_id', 'class_section_name']; @@ -2572,6 +2769,115 @@ class AdministratorController extends BaseController ->findAll(); } + private function removedPriorYearStudentStatuses(string $selectedYear): array + { + $selectedStartYear = $this->getSchoolYearStartYear($selectedYear); + if ($selectedStartYear === null || ! $this->db->tableExists('enrollments')) { + return []; + } + + $select = ['student_id', 'school_year']; + $hasIsWithdrawn = $this->db->fieldExists('is_withdrawn', 'enrollments'); + $hasEnrollmentStatus = $this->db->fieldExists('enrollment_status', 'enrollments'); + $hasAdmissionStatus = $this->db->fieldExists('admission_status', 'enrollments'); + + if ($hasIsWithdrawn) { + $select[] = 'is_withdrawn'; + } + if ($hasEnrollmentStatus) { + $select[] = 'enrollment_status'; + } + if ($hasAdmissionStatus) { + $select[] = 'admission_status'; + } + + if (! $hasIsWithdrawn && ! $hasEnrollmentStatus && ! $hasAdmissionStatus) { + return []; + } + + $builder = $this->db->table('enrollments') + ->select(implode(', ', $select)) + ->where('student_id IS NOT NULL', null, false) + ->where('school_year IS NOT NULL', null, false) + ->groupStart(); + + $hasRemovalCondition = false; + if ($hasIsWithdrawn) { + $builder->where('is_withdrawn', 1); + $hasRemovalCondition = true; + } + + if ($hasEnrollmentStatus) { + if ($hasRemovalCondition) { + $builder->orWhereIn('enrollment_status', ['withdrawn', 'widthran', 'denied']); + } else { + $builder->whereIn('enrollment_status', ['withdrawn', 'widthran', 'denied']); + } + $hasRemovalCondition = true; + } + + if ($hasAdmissionStatus) { + if ($hasRemovalCondition) { + $builder->orWhere('admission_status', 'denied'); + } else { + $builder->where('admission_status', 'denied'); + } + $hasRemovalCondition = true; + } + + $builder->groupEnd(); + if (! $hasRemovalCondition) { + return []; + } + + $removedPriorStatuses = []; + foreach ($builder->get()->getResultArray() as $row) { + $rowYear = $this->getSchoolYearStartYear((string)($row['school_year'] ?? '')); + $studentId = (int)($row['student_id'] ?? 0); + if ($studentId <= 0 || $rowYear === null || $rowYear >= $selectedStartYear) { + continue; + } + + $status = $this->priorRemovedEnrollmentStatus($row); + if ($status === null) { + continue; + } + + if ( + !isset($removedPriorStatuses[$studentId]) + || $rowYear > (int)$removedPriorStatuses[$studentId]['year'] + ) { + $removedPriorStatuses[$studentId] = [ + 'year' => $rowYear, + 'status' => $status, + ]; + } + } + + $statusByStudentId = []; + foreach ($removedPriorStatuses as $studentId => $row) { + $statusByStudentId[(int)$studentId] = (string)$row['status']; + } + + return $statusByStudentId; + } + + private function priorRemovedEnrollmentStatus(array $row): ?string + { + $enrollmentStatus = strtolower(trim((string)($row['enrollment_status'] ?? ''))); + $admissionStatus = strtolower(trim((string)($row['admission_status'] ?? ''))); + + if ($enrollmentStatus === 'denied' || $admissionStatus === 'denied') { + return 'denied'; + } + + if (in_array($enrollmentStatus, ['withdrawn', 'widthran'], true) || (int)($row['is_withdrawn'] ?? 0) === 1) { + return 'withdrawn'; + } + + return null; + } + private function priorYearStudentIds(string $selectedYear): array { $selectedStartYear = $this->getSchoolYearStartYear($selectedYear); @@ -2674,6 +2980,7 @@ class AdministratorController extends BaseController $validStatuses = [ 'admission under review', + 'review & decision', 'payment pending', 'enrolled', 'withdraw under review', @@ -2921,6 +3228,7 @@ class AdministratorController extends BaseController // === AFTER COMMIT: fire specific events, batched per parent/status === $eventMap = [ 'admission under review' => 'admissionUnderReview', + 'review & decision' => 'admissionUnderReview', 'payment pending' => 'paymentPending', 'enrolled' => 'studentEnrolled', 'withdraw under review' => 'withdrawUnderReview', diff --git a/app/Controllers/View/AssignmentController.php b/app/Controllers/View/AssignmentController.php index d0ba0cd..757f450 100644 --- a/app/Controllers/View/AssignmentController.php +++ b/app/Controllers/View/AssignmentController.php @@ -46,9 +46,35 @@ class AssignmentController extends BaseController // Apply school year filter (default to current config) but avoid semester filtering so the full year is visible $selectedSemester = (string)($this->request->getGet('semester') ?? $this->semester ?? ''); - $year = (string)($this->schoolYear ?? ''); + $year = trim((string)($this->request->getGet('schoolYear') ?? $this->request->getGet('school_year') ?? '')); + if ($year === '') { + $year = $this->currentSchoolYearName((string)($this->schoolYear ?? '')); + } $this->applyPendingDistributionDraftsForEnrolledStudents($year); + $this->repairMissingEnrollmentClassAssignments($year); + $distributedSectionIds = array_values(array_unique(array_merge( + $this->distributedClassSectionIds($year), + $this->enrollmentAssignedClassSectionIds($year) + ))); + + $classSectionsAll = []; + if (!empty($distributedSectionIds)) { + $classSectionsAll = $this->classSectionModel + ->select('id, class_section_id, class_section_name, school_year') + ->where('school_year', $year) + ->whereIn('class_section_id', $distributedSectionIds) + ->orderBy('class_section_name', 'ASC') + ->findAll(); + } + + $classSectionById = []; + foreach ($classSectionsAll as $section) { + $sectionId = (int)($section['class_section_id'] ?? 0); + if ($sectionId > 0) { + $classSectionById[$sectionId] = $section; + } + } $tcQ = $this->teacherClassModel; if ($year !== '') { @@ -73,20 +99,26 @@ class AssignmentController extends BaseController $studentsBySection[$sc['class_section_id']][] = $sc; } - $allSectionIds = array_unique(array_merge(array_keys($teacherBySection), array_keys($studentsBySection))); + $allSectionIds = array_unique(array_merge(array_keys($classSectionById), array_keys($teacherBySection), array_keys($studentsBySection))); + $distributedSectionSet = array_fill_keys($distributedSectionIds, true); foreach ($allSectionIds as $classSectionId) { + if (!isset($distributedSectionSet[(int)$classSectionId])) { + continue; + } + $teacherClasses = $teacherBySection[$classSectionId] ?? []; $studentClasses = $studentsBySection[$classSectionId] ?? []; $hasTeacher = !empty($teacherClasses); $hasStudents = !empty($studentClasses); + $hasClassSection = isset($classSectionById[(int)$classSectionId]); - if (!$hasTeacher && !$hasStudents) { + if (!$hasClassSection && !$hasTeacher && !$hasStudents) { continue; } - $classSectionName = (string) ($this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? ''); + $classSectionName = (string) ($classSectionById[(int)$classSectionId]['class_section_name'] ?? $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? ''); $mainTeachers = []; $teacherAssistants = []; @@ -176,12 +208,19 @@ class AssignmentController extends BaseController $schoolYearsList = []; try { $db = Database::connect(); - $yearsQuery = $db->table('teacher_class') + $yearsQuery = $db->table('classSection') ->select('DISTINCT school_year', false) ->where('school_year IS NOT NULL', null, false) ->orderBy('school_year', 'DESC') ->get() ->getResultArray(); + $studentYearsQuery = $db->table('student_class') + ->select('DISTINCT school_year', false) + ->where('school_year IS NOT NULL', null, false) + ->orderBy('school_year', 'DESC') + ->get() + ->getResultArray(); + $yearsQuery = array_merge($yearsQuery, $studentYearsQuery); foreach ($yearsQuery as $row) { $val = (string)($row['school_year'] ?? ''); if ($val !== '' && !in_array($val, $schoolYearsList, true)) { @@ -196,7 +235,7 @@ class AssignmentController extends BaseController } // Sort sections - usort($data['classSections'], fn($a, $b) => strcmp((string) $a['class_section_name'], (string) $b['class_section_name'])); + usort($data['classSections'], fn($a, $b) => strnatcasecmp((string) $a['class_section_name'], (string) $b['class_section_name'])); $data['schoolYears'] = $schoolYearsList; $data['schoolYear'] = $year; @@ -206,6 +245,205 @@ class AssignmentController extends BaseController return view('administrator/class_assignment', $data); } + private function distributedClassSectionIds(string $year): array + { + $year = trim($year); + if ($year === '') { + return []; + } + + try { + $db = Database::connect(); + if (! $db->tableExists('student_section_distribution_drafts')) { + return []; + } + + $baseRows = $db->table('classSection') + ->select('class_id, class_section_id, class_section_name') + ->where('school_year', $year) + ->where("class_section_name NOT LIKE '%-%'", null, false) + ->orderBy('class_id', 'ASC') + ->get() + ->getResultArray(); + + $draftRows = $db->table('student_section_distribution_drafts d') + ->select('d.class_id, d.class_section_id, COALESCE(cs.class_section_name, d.class_section_id) AS class_section_name', false) + ->join( + 'classSection cs', + 'cs.class_section_id = d.class_section_id AND cs.school_year = d.school_year', + 'left', + false + ) + ->where('d.school_year', $year) + ->where('d.class_section_id >', 0) + ->whereIn('status', ['pending', 'applied']) + ->get() + ->getResultArray(); + + $draftsByClassId = []; + foreach ($draftRows as $row) { + $classId = (int)($row['class_id'] ?? 0); + $sectionId = (int)($row['class_section_id'] ?? 0); + if ($classId <= 0 || $sectionId <= 0) { + continue; + } + + $draftsByClassId[$classId][$sectionId] = [ + 'class_section_id' => $sectionId, + 'class_section_name' => (string)($row['class_section_name'] ?? ''), + ]; + } + + $allowed = []; + $seenClassIds = []; + foreach ($baseRows as $row) { + $classId = (int)($row['class_id'] ?? 0); + $baseSectionId = (int)($row['class_section_id'] ?? 0); + $baseName = trim((string)($row['class_section_name'] ?? '')); + if ($classId <= 0 || $baseSectionId <= 0 || $baseName === '') { + continue; + } + + $normalized = strtolower($baseName); + $isAutoDistributeBase = $normalized === 'youth' + || (ctype_digit($normalized) && (int)$normalized >= 1 && (int)$normalized <= 10); + if (!$isAutoDistributeBase) { + continue; + } + + $seenClassIds[$classId] = true; + $classDrafts = $draftsByClassId[$classId] ?? []; + $hasBaseDraft = false; + $letteredDraftIds = []; + foreach ($classDrafts as $draft) { + $draftSectionId = (int)($draft['class_section_id'] ?? 0); + $draftName = trim((string)($draft['class_section_name'] ?? '')); + if ($draftSectionId === $baseSectionId || $draftName === '' || strpos($draftName, '-') === false) { + $hasBaseDraft = true; + continue; + } + + $letteredDraftIds[] = $draftSectionId; + } + + if ($hasBaseDraft || empty($letteredDraftIds)) { + $allowed[] = $baseSectionId; + continue; + } + + foreach ($letteredDraftIds as $draftSectionId) { + $allowed[] = $draftSectionId; + } + } + + foreach ($draftsByClassId as $classId => $classDrafts) { + if (isset($seenClassIds[$classId])) { + continue; + } + + foreach ($classDrafts as $draft) { + $sectionId = (int)($draft['class_section_id'] ?? 0); + if ($sectionId > 0) { + $allowed[] = $sectionId; + } + } + } + + return array_values(array_unique(array_filter( + $allowed, + static fn(int $sectionId): bool => $sectionId > 0 + ))); + } catch (\Throwable $e) { + log_message('error', 'distributedClassSectionIds failed: ' . $e->getMessage()); + return []; + } + } + + private function enrollmentAssignedClassSectionIds(string $year): array + { + $year = trim($year); + if ($year === '') { + return []; + } + + try { + $db = Database::connect(); + $allowedStatuses = ['admission under review', 'review & decision', 'payment pending', 'enrolled']; + $ids = []; + + if ($db->tableExists('enrollments')) { + $builder = $db->table('enrollments e') + ->select('e.class_section_id') + ->join('students s', 's.id = e.student_id', 'inner') + ->where('e.school_year', $year) + ->whereIn('e.enrollment_status', $allowedStatuses) + ->where('e.class_section_id IS NOT NULL', null, false) + ->where('e.class_section_id >', 0); + + if ($db->fieldExists('is_active', 'students')) { + $builder->where('s.is_active', 1); + } + if ($db->fieldExists('is_withdrawn', 'enrollments')) { + $builder->groupStart() + ->where('e.is_withdrawn', 0) + ->orWhere('e.is_withdrawn', null) + ->groupEnd(); + } + + foreach ($builder->groupBy('e.class_section_id')->get()->getResultArray() as $row) { + $sectionId = (int)($row['class_section_id'] ?? 0); + if ($sectionId > 0) { + $ids[] = $sectionId; + } + } + } + + if ($db->tableExists('student_class') && $db->tableExists('enrollments')) { + $builder = $db->table('student_class sc') + ->select('sc.class_section_id') + ->join( + 'enrollments e', + 'e.student_id = sc.student_id AND e.school_year = sc.school_year', + 'inner', + false + ) + ->join('students s', 's.id = sc.student_id', 'inner') + ->where('sc.school_year', $year) + ->whereIn('e.enrollment_status', $allowedStatuses) + ->where('sc.class_section_id IS NOT NULL', null, false) + ->where('sc.class_section_id >', 0); + + if ($db->fieldExists('is_active', 'students')) { + $builder->where('s.is_active', 1); + } + if ($db->fieldExists('is_event_only', 'student_class')) { + $builder->groupStart() + ->where('sc.is_event_only', 0) + ->orWhere('sc.is_event_only', null) + ->groupEnd(); + } + if ($db->fieldExists('is_withdrawn', 'enrollments')) { + $builder->groupStart() + ->where('e.is_withdrawn', 0) + ->orWhere('e.is_withdrawn', null) + ->groupEnd(); + } + + foreach ($builder->groupBy('sc.class_section_id')->get()->getResultArray() as $row) { + $sectionId = (int)($row['class_section_id'] ?? 0); + if ($sectionId > 0) { + $ids[] = $sectionId; + } + } + } + + return array_values(array_unique($ids)); + } catch (\Throwable $e) { + log_message('error', 'enrollmentAssignedClassSectionIds failed: ' . $e->getMessage()); + return []; + } + } + private function applyPendingDistributionDraftsForEnrolledStudents(string $year): void { if ($year === '') { @@ -227,7 +465,7 @@ class AssignmentController extends BaseController ) ->where('d.school_year', $year) ->where('d.status', 'pending') - ->whereIn('e.enrollment_status', ['payment pending', 'enrolled']) + ->whereIn('e.enrollment_status', ['admission under review', 'payment pending', 'enrolled']) ->groupBy('d.id, d.student_id, d.class_section_id') ->get() ->getResultArray(); @@ -274,10 +512,12 @@ class AssignmentController extends BaseController $db->table('enrollments') ->where('student_id', $studentId) ->where('school_year', $year) - ->whereIn('enrollment_status', ['payment pending', 'enrolled']) + ->whereIn('enrollment_status', ['admission under review', 'payment pending', 'enrolled']) ->update([ - 'class_section_id' => $sectionId, - 'updated_at' => $now, + 'class_section_id' => $sectionId, + 'assigned_class_section_id' => $sectionId, + 'placement_status' => 'automatic_distribution_applied', + 'updated_at' => $now, ]); $db->table('student_section_distribution_drafts') @@ -294,6 +534,188 @@ class AssignmentController extends BaseController } } + private function repairMissingEnrollmentClassAssignments(string $year): void + { + if ($year === '') { + return; + } + + try { + $db = Database::connect(); + $this->ensureClassSectionsForYear($db, $year); + if (! $db->tableExists('enrollments') || ! $db->tableExists('student_class')) { + return; + } + + $previousYear = $this->previousSchoolYearName($year); + if ($previousYear === null) { + return; + } + + $rows = $db->table('enrollments e') + ->select('e.student_id, e.school_year, e.enrollment_status, e.class_section_id, sc.id AS student_class_id') + ->join('student_class sc', 'sc.student_id = e.student_id AND sc.school_year = e.school_year', 'left') + ->where('e.school_year', $year) + ->whereIn('e.enrollment_status', ['admission under review', 'payment pending', 'enrolled']) + ->groupStart() + ->where('e.class_section_id', null) + ->orWhere('e.class_section_id', 0) + ->orWhere('sc.id', null) + ->groupEnd() + ->groupBy('e.student_id, e.school_year, e.enrollment_status, e.class_section_id, sc.id') + ->get() + ->getResultArray(); + + foreach ($rows as $row) { + $studentId = (int)($row['student_id'] ?? 0); + if ($studentId <= 0) { + continue; + } + + $evaluation = service('enrollmentTransition')->evaluate($studentId, $previousYear, $year, 'admin'); + if (!($evaluation['academic_eligible'] ?? false) || ($evaluation['blockers'] ?? []) !== []) { + continue; + } + + $targetSectionId = (int)($evaluation['assigned_class_section_id'] ?? 0); + if ($targetSectionId <= 0 && (int)($evaluation['assigned_grade_id'] ?? 0) > 0) { + $base = $this->baseSectionForClassYear((int)$evaluation['assigned_grade_id'], $year); + $targetSectionId = (int)($base['class_section_id'] ?? 0); + } + + if ($targetSectionId <= 0) { + continue; + } + + $placementStatus = match ((string)($evaluation['placement_status'] ?? '')) { + 'automatic_distribution_pending' => 'base_section_pending_distribution', + 'same_class_assigned', 'temporary_same_grade' => (string)$evaluation['placement_status'], + default => 'manual_class_assigned', + }; + + $existing = $db->table('student_class') + ->select('id') + ->where('student_id', $studentId) + ->where('school_year', $year) + ->orderBy('id', 'DESC') + ->limit(1) + ->get() + ->getRowArray(); + + $studentClassPayload = [ + 'student_id' => $studentId, + 'class_section_id' => $targetSectionId, + 'school_year' => $year, + 'updated_by' => (int)(session()->get('user_id') ?? 0) ?: null, + 'updated_at' => utc_now(), + ]; + + if ($existing !== null) { + $db->table('student_class')->where('id', (int)$existing['id'])->update($studentClassPayload); + } else { + $studentClassPayload['created_at'] = utc_now(); + $db->table('student_class')->insert($studentClassPayload); + } + + $db->table('enrollments') + ->where('student_id', $studentId) + ->where('school_year', $year) + ->whereIn('enrollment_status', ['admission under review', 'payment pending', 'enrolled']) + ->update([ + 'class_section_id' => $targetSectionId, + 'assigned_class_section_id' => $targetSectionId, + 'placement_status' => $placementStatus, + 'updated_at' => utc_now(), + ]); + } + } catch (\Throwable $e) { + log_message('error', 'repairMissingEnrollmentClassAssignments failed: ' . $e->getMessage()); + } + } + + private function baseSectionForClassYear(int $classId, string $schoolYear): ?array + { + $db = Database::connect(); + $this->ensureClassSectionsForYear($db, $schoolYear); + if ($classId <= 0 || $schoolYear === '' || ! $db->tableExists('classSection')) { + return null; + } + + $builder = $db->table('classSection') + ->select('class_section_id, class_section_name, class_id') + ->where('class_id', $classId) + ->where("class_section_name NOT LIKE '%-%'", null, false) + ->orderBy('id', 'ASC') + ->limit(1); + + if ($db->fieldExists('school_year', 'classSection')) { + $builder->where('school_year', $schoolYear); + } + + $row = $builder->get()->getRowArray(); + + return $row !== null && (int)($row['class_section_id'] ?? 0) > 0 ? $row : null; + } + + private function ensureClassSectionsForYear($db, string $targetSchoolYear): void + { + $targetSchoolYear = trim($targetSchoolYear); + if ($targetSchoolYear === '' || ! $db->tableExists('classSection') || ! $db->fieldExists('school_year', 'classSection')) { + return; + } + + if ($db->table('classSection')->where('school_year', $targetSchoolYear)->countAllResults() > 0) { + return; + } + + $sourceSchoolYear = $this->previousSchoolYearName($targetSchoolYear); + if ($sourceSchoolYear === null) { + return; + } + + $sourceRows = $db->table('classSection') + ->select('class_id, class_section_id, class_section_name') + ->where('school_year', $sourceSchoolYear) + ->orderBy('id', 'ASC') + ->get() + ->getResultArray(); + + $now = utc_now(); + foreach ($sourceRows as $row) { + $classSectionId = (int)($row['class_section_id'] ?? 0); + if ($classSectionId <= 0) { + continue; + } + + $exists = $db->table('classSection') + ->where('school_year', $targetSchoolYear) + ->where('class_section_id', $classSectionId) + ->countAllResults(); + if ($exists > 0) { + continue; + } + + $db->table('classSection')->insert([ + 'class_id' => (int)($row['class_id'] ?? 0), + 'class_section_id' => $classSectionId, + 'class_section_name' => (string)($row['class_section_name'] ?? ''), + 'school_year' => $targetSchoolYear, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + private function 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); + } + public function save() @@ -316,8 +738,46 @@ class AssignmentController extends BaseController // API: JSON payload for Classes List page public function classAssignmentData() { - $teacherClassesAll = $this->teacherClassModel->findAll(); - $studentClassesAll = $this->studentClassModel->findAll(); + $year = trim((string)($this->request->getGet('schoolYear') ?? $this->request->getGet('school_year') ?? '')); + if ($year === '') { + $year = $this->currentSchoolYearName((string)($this->schoolYear ?? '')); + } + $this->applyPendingDistributionDraftsForEnrolledStudents($year); + $this->repairMissingEnrollmentClassAssignments($year); + $distributedSectionIds = array_values(array_unique(array_merge( + $this->distributedClassSectionIds($year), + $this->enrollmentAssignedClassSectionIds($year) + ))); + + $classSectionsAll = []; + if (!empty($distributedSectionIds)) { + $classSectionsAll = $this->classSectionModel + ->select('id, class_section_id, class_section_name, school_year') + ->where('school_year', $year) + ->whereIn('class_section_id', $distributedSectionIds) + ->orderBy('class_section_name', 'ASC') + ->findAll(); + } + + $classSectionById = []; + foreach ($classSectionsAll as $section) { + $sectionId = (int)($section['class_section_id'] ?? 0); + if ($sectionId > 0) { + $classSectionById[$sectionId] = $section; + } + } + + $tcQ = $this->teacherClassModel; + if ($year !== '') { + $tcQ = $tcQ->where('school_year', $year); + } + $teacherClassesAll = $tcQ->findAll(); + + $scQ = $this->studentClassModel->active(); + if ($year !== '') { + $scQ = $scQ->where('student_class.school_year', $year); + } + $studentClassesAll = $scQ->findAll(); // Group by section $teacherBySection = []; @@ -331,16 +791,22 @@ class AssignmentController extends BaseController if ($secId) $studentsBySection[$secId][] = $sc; } - $allSectionIds = array_values(array_unique(array_merge(array_keys($teacherBySection), array_keys($studentsBySection)))); + $allSectionIds = array_values(array_unique(array_merge(array_keys($classSectionById), array_keys($teacherBySection), array_keys($studentsBySection)))); + $distributedSectionSet = array_fill_keys($distributedSectionIds, true); $classSections = []; foreach ($allSectionIds as $classSectionId) { + if (!isset($distributedSectionSet[(int)$classSectionId])) { + continue; + } + $hasTeacher = !empty($teacherBySection[$classSectionId]); $hasStudents = !empty($studentsBySection[$classSectionId]); - if (!$hasTeacher && !$hasStudents) continue; + $hasClassSection = isset($classSectionById[(int)$classSectionId]); + if (!$hasClassSection && !$hasTeacher && !$hasStudents) continue; - $classSectionName = (string) ($this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? ''); + $classSectionName = (string) ($classSectionById[(int)$classSectionId]['class_section_name'] ?? $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? ''); $mainTeachers = []; $teacherAssistants = []; @@ -374,12 +840,18 @@ class AssignmentController extends BaseController // Load students for the section $students = []; - foreach ($this->studentClassModel->active()->where('student_class.class_section_id', $classSectionId)->findAll() as $studentClass) { + $seenStudentIds = []; + foreach ($studentsBySection[$classSectionId] ?? [] as $studentClass) { + $sid = (int)($studentClass['student_id'] ?? 0); + if ($sid <= 0 || isset($seenStudentIds[$sid])) { + continue; + } $stu = $this->studentModel - ->where('id', (int)$studentClass['student_id']) + ->where('id', $sid) ->where('is_active', 1) ->first(); if (!$stu) continue; + $students[] = [ 'id' => (int)$stu['id'], 'firstname' => (string)($stu['firstname'] ?? ''), @@ -391,6 +863,7 @@ class AssignmentController extends BaseController 'tuition_paid' => (bool)($stu['tuition_paid'] ?? false), 'school_id' => (string)($stu['school_id'] ?? ''), ]; + $seenStudentIds[$sid] = true; } $classSections[] = [ @@ -406,7 +879,7 @@ class AssignmentController extends BaseController } // Sort by class_section_name - usort($classSections, fn($a, $b) => strcmp((string)($a['class_section_name'] ?? ''), (string)($b['class_section_name'] ?? ''))); + usort($classSections, fn($a, $b) => strnatcasecmp((string)($a['class_section_name'] ?? ''), (string)($b['class_section_name'] ?? ''))); return $this->response->setJSON([ 'classSections' => $classSections, diff --git a/app/Controllers/View/EnrollmentAdminController.php b/app/Controllers/View/EnrollmentAdminController.php new file mode 100644 index 0000000..4bb58e0 --- /dev/null +++ b/app/Controllers/View/EnrollmentAdminController.php @@ -0,0 +1,633 @@ +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') ?? '')); + + $flags = $this->enrollmentFlags($schoolYear, $status, $flagType, $assignedTo); + + 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(), + 'enrollmentFollowups' => $this->enrollmentFollowups($schoolYear), + 'auditRows' => $this->auditRows($schoolYear), + 'launchState' => $this->launchState($schoolYear), + 'previewParentId' => $this->firstParentWithStudents(), + 'emailExamples' => service('enrollmentRegistrationEmail')->previewExamplesForSchoolYear($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 { + $flag = $this->requireFlag($id); + $reason = trim((string) ($this->request->getPost('reason') ?? '')); + if ($reason === '') { + return redirect()->back()->with('error', 'Approval reason is required.'); + } + + $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'), + ]); + + $this->resolveFlagRow($flag, $reason, 'enrollment_exception_approved'); + return redirect()->back()->with('success', 'Enrollment exception approved.'); + } catch (Throwable $e) { + return redirect()->back()->with('error', $e->getMessage()); + } + } + + 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') + ->select('u.firstname AS assignee_firstname, u.lastname AS assignee_lastname') + ->join('students s', 's.id = ef.student_id', 'left') + ->join('users u', 'u.id = ef.assigned_to', '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); + } + + $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['assignee_name'] = trim((string) ($row['assignee_firstname'] ?? '') . ' ' . (string) ($row['assignee_lastname'] ?? '')); + $row['details'] = json_decode((string) ($row['details_json'] ?? ''), true) ?: []; + } + unset($row); + + return $rows; + } + + private function enrollmentFollowups(string $schoolYear): array + { + if (! $this->db->tableExists('enrollments')) { + return []; + } + + $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', + 'repeat_class', + 'deferred', + 'expelled', + '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 $rows; + } + + 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 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 = $this->latestEnrollment($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.'); + } + + 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 $this->db->table('enrollments') + ->where('student_id', $studentId) + ->where('school_year', $schoolYear) + ->orderBy('updated_at', 'DESC') + ->orderBy('id', 'DESC') + ->limit(1) + ->get() + ->getRowArray() ?: null; + } + + 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 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 flagTypes(): array + { + if (! $this->db->tableExists('enrollment_flags')) { + return []; + } + + return array_column($this->db->table('enrollment_flags')->select('flag_type')->distinct()->orderBy('flag_type')->get()->getResultArray(), 'flag_type'); + } + + 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; + } +} diff --git a/app/Controllers/View/EventController.php b/app/Controllers/View/EventController.php index 2d4e3be..23feb68 100644 --- a/app/Controllers/View/EventController.php +++ b/app/Controllers/View/EventController.php @@ -92,6 +92,13 @@ class EventController extends ResourceController return $this->eventChargesHasCreatedBy; } + private function assertSchoolYearNameWritable(string $schoolYear): void + { + service('schoolYearWriteGuard')->assertWritable( + service('schoolYearContext')->forYearName($schoolYear) + ); + } + private function currentSchoolYearName(): string { try { @@ -939,6 +946,7 @@ class EventController extends ResourceController if (!$charge) { return redirect()->to($returnTo)->with('error', 'Charge not found.'); } + $this->assertSchoolYearNameWritable((string)($charge['school_year'] ?? '')); $paymentId = (int)($charge['event_payment_id'] ?? 0); if ($paymentId > 0) { @@ -989,6 +997,7 @@ class EventController extends ResourceController if (!$charge) { return redirect()->to($returnTo)->with('error', 'Charge not found.'); } + $this->assertSchoolYearNameWritable((string)($charge['school_year'] ?? '')); $signed = $this->request->getPost('waiver_signed') === '1'; @@ -1070,6 +1079,7 @@ class EventController extends ResourceController if (!$charge) { return null; } + $this->assertSchoolYearNameWritable((string)($charge['school_year'] ?? '')); $event = $this->eventModel->find($charge['event_id']); $eventAmount = max(0.0, (float)($event['amount'] ?? 0)); diff --git a/app/Controllers/View/ExpenseController.php b/app/Controllers/View/ExpenseController.php index 5a55c2c..d94b862 100644 --- a/app/Controllers/View/ExpenseController.php +++ b/app/Controllers/View/ExpenseController.php @@ -261,6 +261,7 @@ class ExpenseController extends BaseController if (!$expense) { throw new \RuntimeException('Expense not found'); } + $this->assertSchoolYearNameWritable((string)($expense['school_year'] ?? '')); $db->query( "SELECT id FROM reimbursements WHERE expense_id = ? AND LOWER(status) NOT IN ('reversed','rejected','cancelled','canceled','voided') FOR UPDATE", [$id] @@ -338,6 +339,7 @@ class ExpenseController extends BaseController if (!$expense) { throw PageNotFoundException::forPageNotFound("Expense #$id not found"); } + $this->assertSchoolYearNameWritable((string)($expense['school_year'] ?? '')); // Base rules $rules = [ diff --git a/app/Controllers/View/FamilyAdminController.php b/app/Controllers/View/FamilyAdminController.php index c230948..283b482 100644 --- a/app/Controllers/View/FamilyAdminController.php +++ b/app/Controllers/View/FamilyAdminController.php @@ -254,7 +254,41 @@ class FamilyAdminController extends BaseController LIMIT 1", [$studentId] )->getRowArray(); - if (!empty($row['id'])) $familyId = (int) $row['id']; + if (!empty($row['id'])) { + $familyId = (int) $row['id']; + } else { + // Legacy/newly-created students can have students.parent_id set + // before the normalized family_students row is created. + $row = $db->query( + "SELECT f.id + FROM students s + JOIN family_guardians fg ON fg.user_id = s.parent_id + JOIN families f ON f.id = fg.family_id + WHERE s.id = ? + ORDER BY fg.is_primary DESC, f.household_name + LIMIT 1", + [$studentId] + )->getRowArray(); + + if (!empty($row['id'])) { + $familyId = (int) $row['id']; + } else { + $row = $db->query( + "SELECT f.id + FROM students s + JOIN families f ON f.family_code = CONCAT('FAM-', s.parent_id) + WHERE s.id = ? + AND s.parent_id IS NOT NULL + ORDER BY f.household_name + LIMIT 1", + [$studentId] + )->getRowArray(); + + if (!empty($row['id'])) { + $familyId = (int) $row['id']; + } + } + } } elseif ($guardianId) { // 1) Try via guardians link $row = $db->query( @@ -330,6 +364,24 @@ class FamilyAdminController extends BaseController ORDER BY s.lastname, s.firstname", [$familyId] )->getResultArray(); + + if ($studentId > 0) { + $studentIds = array_map(static fn(array $row): int => (int) ($row['id'] ?? 0), $studentsRows); + if (!in_array($studentId, $studentIds, true)) { + $selectedStudent = $db->query( + "SELECT id, firstname, lastname + FROM students + WHERE id = ? + LIMIT 1", + [$studentId] + )->getRowArray(); + + if ($selectedStudent) { + $studentsRows[] = $selectedStudent; + } + } + } + if (!empty($studentsRows)) { foreach ($studentsRows as &$sr) { $sid = (int) ($sr['id'] ?? 0); diff --git a/app/Controllers/View/FlagController.php b/app/Controllers/View/FlagController.php index d141d23..d1ef50d 100644 --- a/app/Controllers/View/FlagController.php +++ b/app/Controllers/View/FlagController.php @@ -24,6 +24,13 @@ class FlagController extends Controller helper(['url', 'form']); } + private function assertSchoolYearNameWritable(string $schoolYear): void + { + service('schoolYearWriteGuard')->assertWritable( + service('schoolYearContext')->forYearName($schoolYear) + ); + } + public function index() { $currentFlagModel = new CurrentFlagModel(); @@ -436,6 +443,13 @@ class FlagController extends Controller return $this->index(); } + $flagData = $currentFlagModel->find($id); + if (!$flagData) { + session()->setFlashdata('error', 'Incident not found.'); + return $this->index(); + } + $this->assertSchoolYearNameWritable((string)($flagData['school_year'] ?? '')); + $update = ['flag_state' => $newState]; if ($newState === 'Closed') { $update['updated_by_closed'] = $userId; @@ -490,6 +504,7 @@ class FlagController extends Controller session()->setFlashdata('error', 'Incident not found.'); return redirect()->back(); } + $this->assertSchoolYearNameWritable((string)($flagData['school_year'] ?? '')); // Proceed only if flag is not closed if ($flagData['flag_state'] !== 'Closed') { @@ -537,6 +552,7 @@ class FlagController extends Controller session()->setFlashdata('error', 'Incident not found.'); return redirect()->back(); } + $this->assertSchoolYearNameWritable((string)($flagData['school_year'] ?? '')); // Check if the flag is not already canceled if ($flagData['flag_state'] !== 'Canceled') { diff --git a/app/Controllers/View/GradingController.php b/app/Controllers/View/GradingController.php index 967020c..fc0bd35 100644 --- a/app/Controllers/View/GradingController.php +++ b/app/Controllers/View/GradingController.php @@ -2,10 +2,10 @@ namespace App\Controllers\View; +use App\Controllers\BaseController; use App\Models\StudentModel; use App\Models\StudentClassModel; use App\Models\ConfigurationModel; -use CodeIgniter\Controller; use CodeIgniter\Events\Events; use App\Models\HomeworkModel; use App\Models\QuizModel; @@ -35,7 +35,7 @@ use App\Services\NavbarService; //use App\Models\ScoreModel; -class GradingController extends Controller +class GradingController extends BaseController { protected $semesterScoreService; protected $db; @@ -1119,15 +1119,6 @@ public function belowSixty() ]); } - private function currentSchoolYearName(?string $fallback = null): string - { - try { - return service('schoolYearContext')->resolve($this->request)->yearName(); - } catch (\Throwable) { - return trim((string) ($fallback ?? '')); - } - } - public function editBelowSixtyEmail() { $studentId = (int)$this->request->getGet('student_id'); @@ -2329,7 +2320,8 @@ public function belowSixty() public function belowSixtyDecisions() { - $configuredYear = (string) $this->schoolYear; + $schoolYearContext = $this->resolveSchoolYearContext(); + $configuredYear = $schoolYearContext->yearName(); $schoolYear = trim((string)($this->request->getGet('school_year') ?? '')); @@ -2541,11 +2533,15 @@ public function belowSixty() 'schoolYear' => $schoolYear, 'schoolYears' => $schoolYears, 'canViewGrading' => $canViewGrading, + 'isEditable' => ! $schoolYearContext->isReadonly(), ]); } public function saveBelowSixtyDecision() { + $schoolYearContext = $this->resolveSchoolYearContext(); + $this->assertSchoolYearWritable($schoolYearContext); + $studentId = (int)($this->request->getPost('student_id') ?? 0); $semester = strtolower(trim((string)($this->request->getPost('semester') ?? 'year'))); $schoolYear = trim((string)($this->request->getPost('school_year') ?? '')); @@ -2556,6 +2552,10 @@ public function saveBelowSixtyDecision() return redirect()->back()->with('error', 'Missing student or school year.'); } + if ($schoolYear !== $schoolYearContext->yearName()) { + return redirect()->back()->with('error', 'Selected school year does not match the submitted decision.'); + } + // This decision page should feed certificate decisions as whole-year decisions. // Force year mode here so certificate logic receives final year decision. $semester = 'year'; diff --git a/app/Controllers/View/InvoiceController.php b/app/Controllers/View/InvoiceController.php index 4ce7802..cba2277 100644 --- a/app/Controllers/View/InvoiceController.php +++ b/app/Controllers/View/InvoiceController.php @@ -81,7 +81,8 @@ class InvoiceController extends ResourceController $this->gradeFee = $this->configModel->getConfig('grade_fee'); $this->schoolYear = $this->currentSchoolYearName(); $this->semester = $this->configModel->getConfig('semester'); - $this->dueDate = $this->configModel->getConfig('due_date'); + $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); @@ -230,6 +231,13 @@ class InvoiceController extends ResourceController } } + private function assertSchoolYearNameWritable(string $schoolYear): void + { + service('schoolYearWriteGuard')->assertWritable( + service('schoolYearContext')->forYearName($schoolYear) + ); + } + /** * API: Invoice management composite data (used by invoice_management view) * Returns the same structure previously rendered server-side in index(). @@ -891,7 +899,7 @@ class InvoiceController extends ResourceController } } - $eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear); + $eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear, $invoice['semester'] ?? null); // Attach SCHOOL IDs $allKids = array_merge($registeredKids, $withdrawnKids); @@ -1073,18 +1081,40 @@ class InvoiceController extends ResourceController $pdf->SetFont('Arial', 'B', 12); $pdf->Cell(40, 6, 'Due Date:', 0, 0, 'L'); $pdf->SetFont('Arial', '', 12); - $dueLocal = null; + $formatCalendarDate = static function ($raw): ?string { + if ($raw instanceof \DateTimeInterface) { + return $raw->format('m-d-Y'); + } + + $value = trim((string)($raw ?? '')); + if ($value === '' || preg_match('/^0{4}-0{2}-0{2}/', $value)) { + return null; + } + + if (preg_match('/^(\d{4})-(\d{2})-(\d{2})/', $value, $matches)) { + return $matches[2] . '-' . $matches[3] . '-' . $matches[1]; + } + + if (preg_match('/^(\d{1,2})[-\/](\d{1,2})[-\/](\d{4})/', $value, $matches)) { + return sprintf('%02d-%02d-%04d', (int)$matches[1], (int)$matches[2], (int)$matches[3]); + } + + $timestamp = strtotime($value); + return $timestamp === false ? null : date('m-d-Y', $timestamp); + }; + + $dueDisplay = $formatCalendarDate($invoice['due_date'] ?? null); try { $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); - if (!empty($invoice['due_date'])) { - $dueLocal = (new \DateTimeImmutable($invoice['due_date'], new \DateTimeZone('UTC'))) - ->setTimezone(new \DateTimeZone($tzName)); - } elseif (!empty($invoice['created_at'])) { + if ($dueDisplay === null && !empty($invoice['created_at'])) { $dueLocal = new \DateTimeImmutable($invoice['created_at'], new \DateTimeZone($tzName)); + $dueDisplay = $dueLocal->format('m-d-Y'); } } catch (\Throwable $e) {} - if (!$dueLocal) { $dueLocal = new \DateTimeImmutable('now', new \DateTimeZone($tzName ?? 'UTC')); } - $pdf->Cell(0, 6, $dueLocal->format('m-d-Y'), 0, 1, 'L'); + if ($dueDisplay === null) { + $dueDisplay = (new \DateTimeImmutable('now', new \DateTimeZone($tzName ?? 'UTC')))->format('m-d-Y'); + } + $pdf->Cell(0, 6, $dueDisplay, 0, 1, 'L'); $pdf->Ln(5); $pdf->SetFont('Arial', '', 9); @@ -1145,16 +1175,111 @@ class InvoiceController extends ResourceController ]; }; - // --- Frozen invoice charge lines. Do not rebuild issued charges from current enrollment/events. + $studentById = []; + foreach (($data['students'] ?? []) as $student) { + $sid = (int)($student['student_id'] ?? 0); + if ($sid <= 0) { + continue; + } + $studentById[$sid] = trim((string)($student['student_firstname'] ?? '') . ' ' . (string)($student['student_lastname'] ?? '')); + } + + $studentTuitionRows = []; + foreach (array_merge($registeredKids ?? [], $withdrawnKids ?? []) as $student) { + $sid = (int)($student['student_id'] ?? 0); + $charge = $studentCharges[$sid] ?? null; + $amount = (float)($charge['unit_fee'] ?? 0.0); + if ($sid <= 0 || abs($amount) < 0.00001) { + continue; + } + + $name = trim((string)($student['student_firstname'] ?? '') . ' ' . (string)($student['student_lastname'] ?? '')); + $grade = trim((string)($student['grade'] ?? '')); + $desc = 'Tuition - ' . ($name !== '' ? $name : 'Student #' . $sid); + if ($grade !== '' && strtoupper($grade) !== 'N/A') { + $desc .= ' (' . $grade . ')'; + } + + $studentTuitionRows[] = [ + 'description' => $desc, + 'amount' => $amount, + ]; + } + + $eventRows = []; + foreach (($events ?? []) as $event) { + $amount = (float)($event['charged'] ?? 0.0); + if (abs($amount) < 0.00001) { + continue; + } + + $sid = (int)($event['student_id'] ?? 0); + $studentName = $sid > 0 ? ($studentById[$sid] ?? '') : ''; + if ($studentName === '') { + $studentName = trim((string)($event['external_firstname'] ?? '') . ' ' . (string)($event['external_lastname'] ?? '')); + } + + $desc = trim((string)($event['event_name'] ?? 'Event charge')); + if ($studentName !== '') { + $desc .= ' - ' . $studentName; + } + + $eventRows[] = [ + 'description' => $desc, + 'amount' => $amount, + ]; + } + + // --- Frozen invoice charge lines remain authoritative for totals. + // Aggregate tuition/event lines are expanded for display when invoice details are available. foreach (($data['invoiceLines'] ?? []) as $line) { $dt = $toLocal($line['created_at'] ?? ($invoice['created_at'] ?? null), true); $amount = ((int)($line['line_amount_cents'] ?? 0)) / 100; $type = (string)($line['line_type'] ?? 'other'); $category = str_contains($type, 'event') ? 'event' : (str_contains($type, 'additional') ? 'additional' : 'registration'); + + if (!str_contains($type, 'additional') && !str_contains($type, 'event') && !empty($studentTuitionRows)) { + $expandedTotal = 0.0; + foreach ($studentTuitionRows as $row) { + $expandedTotal += (float)$row['amount']; + $push($dt, $row['description'], (float)$row['amount'], 'registration'); + } + + $delta = round($amount - $expandedTotal, 2); + if (abs($delta) >= 0.01) { + $push($dt, 'Tuition charges adjustment', $delta, 'registration'); + } + continue; + } + + if (str_contains($type, 'event') && !empty($eventRows)) { + $expandedTotal = 0.0; + foreach ($eventRows as $row) { + $expandedTotal += (float)$row['amount']; + $push($dt, $row['description'], (float)$row['amount'], 'event'); + } + + $delta = round($amount - $expandedTotal, 2); + if (abs($delta) >= 0.01) { + $push($dt, 'Event charges adjustment', $delta, 'event'); + } + continue; + } + $push($dt, (string)($line['description'] ?? 'Invoice line'), $amount, $category); } + if (empty($data['invoiceLines'] ?? [])) { + $fallbackDt = $toLocal($invoice['created_at'] ?? ($invoice['issue_date'] ?? null), true); + foreach ($studentTuitionRows as $row) { + $push($fallbackDt, $row['description'], (float)$row['amount'], 'registration'); + } + foreach ($eventRows as $row) { + $push($fallbackDt, $row['description'], (float)$row['amount'], 'event'); + } + } + // --- Payments (negative) — stored in local time foreach ($payments as $payment) { $dt = $toLocal($payment['payment_date'] ?? null, false /* local */); @@ -1513,6 +1638,12 @@ private function getGradeLevel($grade): array // API: Update invoice status public function updateStatusAPI($invoiceId) { + $invoice = $this->invoiceModel->find($invoiceId); + if (!$invoice) { + return $this->failNotFound('Invoice not found.'); + } + $this->assertSchoolYearNameWritable((string)($invoice['school_year'] ?? '')); + $status = $this->request->getPost('status'); if ($this->invoiceModel->updateInvoiceStatus($invoiceId, $status)) { return $this->respond(['status' => 'success']); @@ -1524,6 +1655,12 @@ private function getGradeLevel($grade): array // View: Update invoice status public function updateStatus($invoiceId) { + $invoice = $this->invoiceModel->find($invoiceId); + if (!$invoice) { + return redirect()->back()->with('error', 'Invoice not found.'); + } + $this->assertSchoolYearNameWritable((string)($invoice['school_year'] ?? '')); + $status = $this->request->getPost('status'); if ($this->invoiceModel->updateInvoiceStatus($invoiceId, $status)) { return redirect()->to('/invoices'); diff --git a/app/Controllers/View/ParentController.php b/app/Controllers/View/ParentController.php index 5c51dbc..4217445 100644 --- a/app/Controllers/View/ParentController.php +++ b/app/Controllers/View/ParentController.php @@ -21,6 +21,8 @@ use CodeIgniter\Events\Events; use App\Services\SchoolIdService; use App\Services\FeeCalculationService; use App\Services\PhoneFormatterService; +use App\Support\Enrollment\DeliberationDecision; +use App\Support\Enrollment\EnrollmentEligibility; use InvalidArgumentException; use DateTimeImmutable; use DateTimeZone; @@ -299,6 +301,7 @@ class ParentController extends BaseController // Map enrollment statuses $statusMap = [ 'admission under review' => 'admission under review', + 'review & decision' => 'review & decision', 'payment pending' => 'payment pending', 'enrolled' => 'enrolled', 'withdraw under review' => 'withdraw under review', @@ -360,12 +363,31 @@ class ParentController extends BaseController // ✅ Updated disable logic to include denied status $student['disable_enroll'] = in_array( $student['enrollment_status'], - ['admission under review', 'payment pending', 'enrolled', 'withdraw under review', 'denied'] + ['admission under review', 'review & decision', 'payment pending', 'enrolled', 'withdraw under review', 'denied'] ); - $student['previous_year_decision'] = $previousSchoolYear !== null - ? $this->studentDecisionForYear((int) $studentId, $previousSchoolYear) + $decisionYear = $isEditable ? $previousSchoolYear : $selectedYear; + $student['previous_year_decision'] = $decisionYear !== null + ? $this->studentDecisionForYear((int) $studentId, $decisionYear) : null; + + if ($isEditable) { + $student['transition_evaluation'] = $previousSchoolYear !== null + ? $this->transitionEvaluationForStudent((int) $studentId, $previousSchoolYear, $selectedYear) + : null; + $student['enrollment_eligibility_message'] = $this->eligibilityMessageFromTransition( + $student, + $student['transition_evaluation'], + $fallMakeupExamOn + ); + $student['expected_placement_label'] = $this->expectedPlacementLabel($student['transition_evaluation']); + $student['required_action_label'] = $this->requiredActionLabel($student['transition_evaluation']); + } 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.'; + } } // Render view @@ -379,6 +401,7 @@ class ParentController extends BaseController 'lastDayOfRegistration' => $this->lastDayOfRegistration, 'schoolStartDate' => $this->schoolStartDate, 'hasAcceptedSchoolPolicy' => $this->hasAcceptedPolicyForYear((int) $parentId, $selectedYear), + 'familyFinancialSummary' => $this->familyFinancialSummary((int) $parentId, $previousSchoolYear, $selectedYear), ]); } catch (Exception $e) { log_message('error', 'An error occurred in enrollClasses: ' . $e->getMessage()); @@ -442,6 +465,11 @@ class ParentController extends BaseController return redirect()->back()->withInput()->with('error', implode(' ', $blockingDecisionMessages)); } + $financialBlockers = $this->financialSubmissionBlockers((int) $parentId, $selectedYear); + if ($financialBlockers !== []) { + return redirect()->back()->withInput()->with('error', implode(' ', $financialBlockers)); + } + foreach ($enroll as $studentId) { // Get student full name (supports both string return or array with firstname/lastname) $studentInfo = $this->studentModel->getFullNameById($studentId); @@ -457,7 +485,7 @@ class ParentController extends BaseController // Check if the student already has an enrollment record for the current school year and semester $existingEnrollment = $this->enrollmentModel ->where('student_id', $studentId) - ->where('school_year', $this->schoolYear) + ->where('school_year', $selectedYear) ->where('semester', $this->semester) ->get() ->getRowArray(); @@ -467,37 +495,52 @@ class ParentController extends BaseController return redirect()->back()->with('error', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']}: Student school ID not found."); } if ($existingEnrollment) { - if ($existingEnrollment['is_withdrawn'] == 1) { - $passedPreviousYear = $this->studentPassedPreviousYear((int)$studentId, (string)$this->schoolYear); + $isReturningReEnrollment = $this->isReturningReEnrollmentStudent((int)$studentId, $selectedYear); + $targetEnrollmentStatus = $isReturningReEnrollment ? 'payment pending' : 'admission under review'; + $targetAdmissionStatus = $isReturningReEnrollment ? 'accepted' : 'pending'; + 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' => $passedPreviousYear ? 'payment pending' : 'admission under review', - 'admission_status' => $passedPreviousYear ? 'accepted' : 'pending', + 'enrollment_status' => $targetEnrollmentStatus, + 'admission_status' => $targetAdmissionStatus, 'updated_at' => utc_now() ]); 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, (string)$this->schoolYear); + $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, (string)$this->schoolYear); + $this->applyPromotionAssignment((int)$studentId, $selectedYear, $isReturningReEnrollment); } } else { - $passedPreviousYear = $this->studentPassedPreviousYear((int)$studentId, (string)$this->schoolYear); + $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([ 'student_id' => $studentId, 'parent_id' => $parentId, - 'school_year' => $this->schoolYear, + 'school_year' => $selectedYear, 'semester' => $this->semester, 'enrollment_date' => local_date(utc_now(), 'Y-m-d'), 'is_withdrawn' => 0, - 'enrollment_status' => $passedPreviousYear ? 'payment pending' : 'admission under review', - 'admission_status' => $passedPreviousYear ? 'accepted' : 'pending', + 'enrollment_status' => $targetEnrollmentStatus, + 'admission_status' => $targetAdmissionStatus, 'created_at' => utc_now() ]); @@ -506,7 +549,7 @@ class ParentController extends BaseController } 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, (string)$this->schoolYear); + $this->applyPromotionAssignment((int)$studentId, $selectedYear, $isReturningReEnrollment); } } } @@ -648,13 +691,23 @@ class ParentController extends BaseController * create/update student_class row accordingly (base section until distribution), * and mark the queue row as applied. */ - private function applyPromotionAssignment(int $studentId, string $year): void + private function applyPromotionAssignment(int $studentId, string $year, bool $updateEnrollmentPlacement = true): void { try { + $this->ensureClassSectionsForYear($year); $promo = new \App\Models\PromotionQueueModel(); $classSectionModel = new \App\Models\ClassSectionModel(); $draftModel = new StudentSectionDistributionDraftModel(); $studentClass = new \App\Models\StudentClassModel(); + $previousYear = $this->previousSchoolYearName($year); + $previousDecision = $previousYear !== null + ? $this->studentDecisionForYear($studentId, $previousYear) + : null; + + if ($this->isFallMakeupExamDecision($previousDecision)) { + $this->keepMakeupExamStudentInPreviousGrade($studentId, $year, $previousYear); + return; + } $draft = $draftModel->where('student_id', $studentId) ->where('school_year', $year) @@ -665,15 +718,41 @@ class ParentController extends BaseController ->where('school_year_to', $year) ->first(); - if (!$row && !$draft) return; // nothing to do - $targetSectionId = (int)($draft['class_section_id'] ?? 0); if ($targetSectionId <= 0) { $targetSectionId = (int)($row['to_class_section_id'] ?? 0); } + + $placementStatus = $draft ? 'automatic_distribution_applied' : 'manual_class_assigned'; + if (!$row && !$draft && $previousYear !== null) { + $evaluation = service('enrollmentTransition')->evaluate($studentId, $previousYear, $year, 'admin'); + if (!($evaluation['academic_eligible'] ?? false) || ($evaluation['blockers'] ?? []) !== []) { + log_message('warning', 'applyPromotionAssignment: transition fallback blocked for student_id=' . $studentId . ', year=' . $year . ', blockers=' . implode('; ', $evaluation['blockers'] ?? [])); + return; + } + + $targetSectionId = (int)($evaluation['assigned_class_section_id'] ?? 0); + if ($targetSectionId <= 0 && (int)($evaluation['assigned_grade_id'] ?? 0) > 0) { + $base = $this->baseSectionForClassYear((int)$evaluation['assigned_grade_id'], $year); + $targetSectionId = (int)($base['class_section_id'] ?? 0); + } + + $placementStatus = match ((string)($evaluation['placement_status'] ?? '')) { + 'automatic_distribution_pending' => 'base_section_pending_distribution', + 'same_class_assigned', 'temporary_same_grade' => (string)$evaluation['placement_status'], + default => 'manual_class_assigned', + }; + } + + if (!$row && !$draft && $targetSectionId <= 0) { + log_message('warning', 'applyPromotionAssignment: no placement source found for student_id=' . $studentId . ', year=' . $year); + return; + } + if ($targetSectionId <= 0) { // Resolve base section for target class (e.g., '3') - $base = $classSectionModel->getBaseSectionByClassId((int)($row['to_class_id'] ?? 0)); + $base = $this->baseSectionForClassYear((int)($row['to_class_id'] ?? 0), $year) + ?? $classSectionModel->getBaseSectionByClassId((int)($row['to_class_id'] ?? 0)); if (!$base) { log_message('warning', 'applyPromotionAssignment: no draft or base section found for student_id=' . $studentId . ', year=' . $year); return; @@ -703,14 +782,18 @@ class ParentController extends BaseController $studentClass->insert($payload); } - $this->db->table('enrollments') - ->where('student_id', $studentId) - ->where('school_year', $year) - ->whereIn('enrollment_status', ['payment pending', 'enrolled']) - ->update([ - 'class_section_id' => $targetSectionId, - 'updated_at' => utc_now(), - ]); + if ($updateEnrollmentPlacement) { + $this->db->table('enrollments') + ->where('student_id', $studentId) + ->where('school_year', $year) + ->whereIn('enrollment_status', ['admission under review', 'review & decision', 'payment pending', 'enrolled']) + ->update([ + 'class_section_id' => $targetSectionId, + 'assigned_class_section_id' => $targetSectionId, + 'placement_status' => $placementStatus, + 'updated_at' => utc_now(), + ]); + } // Mark promotion as applied when promotion_queue is the source. if ($row) { @@ -759,16 +842,17 @@ class ParentController extends BaseController } if ($this->db->tableExists('student_decisions')) { - $passedDecision = $this->db->table('student_decisions') - ->select('id') + $decisionRow = $this->db->table('student_decisions') + ->select('decision') ->where('student_id', $studentId) ->where('school_year', $previousSchoolYear) - ->where('LOWER(TRIM(decision)) = ' . $this->db->escape('pass'), null, false) + ->orderBy('updated_at', 'DESC') + ->orderBy('id', 'DESC') ->limit(1) ->get() ->getRowArray(); - return $passedDecision !== null; + return DeliberationDecision::normalize($decisionRow['decision'] ?? null) === DeliberationDecision::PASSED; } } catch (\Throwable $e) { log_message('error', 'studentPassedPreviousYear failed: ' . $e->getMessage()); @@ -777,6 +861,326 @@ class ParentController extends BaseController return false; } + private function isReturningReEnrollmentStudent(int $studentId, string $targetSchoolYear): bool + { + $previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear); + if ($studentId <= 0 || $previousSchoolYear === null) { + return false; + } + + try { + if ($this->db->tableExists('student_decisions')) { + $decision = $this->db->table('student_decisions') + ->select('id') + ->where('student_id', $studentId) + ->where('school_year', $previousSchoolYear) + ->limit(1) + ->get() + ->getRowArray(); + + if ($decision !== null) { + return true; + } + } + + if ($this->db->tableExists('student_class')) { + $assignment = $this->db->table('student_class') + ->select('id') + ->where('student_id', $studentId) + ->where('school_year', $previousSchoolYear) + ->limit(1) + ->get() + ->getRowArray(); + + if ($assignment !== null) { + return true; + } + } + + if ($this->db->tableExists('enrollments')) { + $enrollment = $this->db->table('enrollments') + ->select('id') + ->where('student_id', $studentId) + ->where('school_year', $previousSchoolYear) + ->limit(1) + ->get() + ->getRowArray(); + + if ($enrollment !== null) { + return true; + } + } + + if ($this->db->tableExists('promotion_queue')) { + $promotion = $this->db->table('promotion_queue') + ->select('id') + ->where('student_id', $studentId) + ->where('school_year_from', $previousSchoolYear) + ->where('school_year_to', $targetSchoolYear) + ->limit(1) + ->get() + ->getRowArray(); + + if ($promotion !== null) { + return true; + } + } + } catch (\Throwable $e) { + log_message('error', 'isReturningReEnrollmentStudent failed: ' . $e->getMessage()); + } + + return false; + } + + private function isFallMakeupExamDecision(?array $decisionRow): bool + { + return DeliberationDecision::normalize($decisionRow['decision'] ?? null) === DeliberationDecision::MAKE_UP_EXAM; + } + + private function keepMakeupExamStudentInPreviousGrade(int $studentId, string $targetSchoolYear, ?string $previousSchoolYear): void + { + if ($studentId <= 0 || $targetSchoolYear === '' || $previousSchoolYear === null) { + return; + } + + $previousAssignment = $this->db->table('student_class sc') + ->select('sc.class_section_id, cs.class_section_name') + ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') + ->where('sc.student_id', $studentId) + ->where('sc.school_year', $previousSchoolYear) + ->where('sc.class_section_id IS NOT NULL', null, false) + ->orderBy('sc.updated_at', 'DESC') + ->orderBy('sc.id', 'DESC') + ->limit(1) + ->get() + ->getRowArray(); + + if ($previousAssignment === null) { + $this->openMakeupExamPlacementFlag($studentId, $targetSchoolYear, null, null); + log_message('warning', 'No previous class assignment found for fall make-up exam student_id=' . $studentId . ', year=' . $previousSchoolYear); + return; + } + + $previousSectionId = (int) ($previousAssignment['class_section_id'] ?? 0); + $previousSectionName = trim((string) ($previousAssignment['class_section_name'] ?? '')); + $targetSectionId = $this->equivalentClassSectionIdForYear($previousSectionId, $previousSectionName, $targetSchoolYear); + + if ($targetSectionId <= 0) { + $targetSectionId = $previousSectionId; + } + + if ($targetSectionId <= 0) { + $this->openMakeupExamPlacementFlag($studentId, $targetSchoolYear, null, $previousSectionName !== '' ? $previousSectionName : null); + return; + } + + $now = utc_now(); + $updatedBy = (int) (session()->get('user_id') ?? 0) ?: null; + $studentClass = new \App\Models\StudentClassModel(); + $existing = $studentClass->where('student_id', $studentId) + ->where('school_year', $targetSchoolYear) + ->first(); + + $payload = [ + 'student_id' => $studentId, + 'class_section_id' => $targetSectionId, + 'school_year' => $targetSchoolYear, + 'updated_by' => $updatedBy, + 'updated_at' => $now, + 'description' => 'Held at previous grade pending fall make-up exam result.', + ]; + + if ($existing) { + $studentClass->update((int) $existing['id'], $payload); + } else { + $payload['created_at'] = $now; + $studentClass->insert($payload); + } + + $this->db->table('enrollments') + ->where('student_id', $studentId) + ->where('school_year', $targetSchoolYear) + ->whereIn('enrollment_status', ['admission under review', 'review & decision', 'payment pending', 'enrolled']) + ->update([ + 'class_section_id' => $targetSectionId, + 'updated_at' => $now, + ]); + + $targetSectionName = $this->classSectionNameById($targetSectionId) ?? $previousSectionName; + $this->openMakeupExamPlacementFlag( + $studentId, + $targetSchoolYear, + $targetSectionId, + $targetSectionName !== '' ? $targetSectionName : null + ); + } + + private function equivalentClassSectionIdForYear(int $sourceSectionId, string $sourceSectionName, string $targetSchoolYear): int + { + if ($sourceSectionName !== '' && $this->db->fieldExists('school_year', 'classSection')) { + $row = $this->db->table('classSection') + ->select('class_section_id') + ->where('class_section_name', $sourceSectionName) + ->where('school_year', $targetSchoolYear) + ->orderBy('id', 'DESC') + ->limit(1) + ->get() + ->getRowArray(); + + if ($row !== null && (int) ($row['class_section_id'] ?? 0) > 0) { + return (int) $row['class_section_id']; + } + } + + return $sourceSectionId; + } + + private function classSectionNameById(int $classSectionId): ?string + { + if ($classSectionId <= 0) { + return null; + } + + $row = $this->db->table('classSection') + ->select('class_section_name') + ->where('class_section_id', $classSectionId) + ->orderBy('id', 'DESC') + ->limit(1) + ->get() + ->getRowArray(); + + $name = trim((string) ($row['class_section_name'] ?? '')); + + return $name !== '' ? $name : null; + } + + private function baseSectionForClassYear(int $classId, string $schoolYear): ?array + { + $this->ensureClassSectionsForYear($schoolYear); + if ($classId <= 0 || $schoolYear === '' || ! $this->db->tableExists('classSection')) { + return null; + } + + $builder = $this->db->table('classSection') + ->select('class_section_id, class_section_name, class_id') + ->where('class_id', $classId) + ->where("class_section_name NOT LIKE '%-%'", null, false) + ->orderBy('id', 'ASC') + ->limit(1); + + if ($this->db->fieldExists('school_year', 'classSection')) { + $builder->where('school_year', $schoolYear); + } + + $row = $builder->get()->getRowArray(); + + return $row !== null && (int) ($row['class_section_id'] ?? 0) > 0 ? $row : null; + } + + private function ensureClassSectionsForYear(string $targetSchoolYear): void + { + $targetSchoolYear = trim($targetSchoolYear); + if ($targetSchoolYear === '' || ! $this->db->tableExists('classSection') || ! $this->db->fieldExists('school_year', 'classSection')) { + return; + } + + if ($this->db->table('classSection')->where('school_year', $targetSchoolYear)->countAllResults() > 0) { + return; + } + + $sourceSchoolYear = $this->previousSchoolYearName($targetSchoolYear); + if ($sourceSchoolYear === null) { + return; + } + + $sourceRows = $this->db->table('classSection') + ->select('class_id, class_section_id, class_section_name') + ->where('school_year', $sourceSchoolYear) + ->orderBy('id', 'ASC') + ->get() + ->getResultArray(); + + $now = utc_now(); + foreach ($sourceRows as $row) { + $classSectionId = (int)($row['class_section_id'] ?? 0); + if ($classSectionId <= 0) { + continue; + } + + $exists = $this->db->table('classSection') + ->where('school_year', $targetSchoolYear) + ->where('class_section_id', $classSectionId) + ->countAllResults(); + if ($exists > 0) { + continue; + } + + $this->db->table('classSection')->insert([ + 'class_id' => (int)($row['class_id'] ?? 0), + 'class_section_id' => $classSectionId, + 'class_section_name' => (string)($row['class_section_name'] ?? ''), + 'school_year' => $targetSchoolYear, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + private function openMakeupExamPlacementFlag( + int $studentId, + string $targetSchoolYear, + ?int $classSectionId, + ?string $classSectionName + ): void { + if ($studentId <= 0 || $targetSchoolYear === '' || ! $this->db->tableExists('current_flag')) { + return; + } + + $student = $this->db->table('students') + ->select('firstname, lastname') + ->where('id', $studentId) + ->limit(1) + ->get() + ->getRowArray(); + $studentName = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')); + $studentName = $studentName !== '' ? $studentName : 'Student ID ' . $studentId; + $heldGradeText = $classSectionName !== null && trim($classSectionName) !== '' + ? ' Student is currently held in ' . trim($classSectionName) . '.' + : ''; + $description = 'Fall make-up exam enrollment: keep student at the closed-year grade until the exam is passed. After passing, place the student in the new grade.' . $heldGradeText; + + $existing = $this->db->table('current_flag') + ->select('id') + ->where('student_id', $studentId) + ->where('school_year', $targetSchoolYear) + ->where('flag', 'grade') + ->where('flag_state', 'Open') + ->where('open_description', $description) + ->limit(1) + ->get() + ->getRowArray(); + + if ($existing !== null) { + return; + } + + $now = utc_now(); + $this->db->table('current_flag')->insert([ + 'student_id' => $studentId, + 'student_name' => $studentName, + 'grade' => $classSectionId !== null && $classSectionId > 0 ? (string) $classSectionId : '', + 'flag' => 'grade', + 'flag_datetime' => $now, + 'flag_state' => 'Open', + 'updated_by_open' => (int) (session()->get('user_id') ?? 0) ?: null, + 'open_description' => $description, + 'semester' => (string) ($this->semester ?? ''), + 'school_year' => $targetSchoolYear, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + private function studentDecisionForYear(int $studentId, string $schoolYear): ?array { if ($studentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('student_decisions')) { @@ -824,37 +1228,325 @@ class ParentController extends BaseController private function blockedEnrollmentDecisionMessages(array $studentIds, string $targetSchoolYear): array { $previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear); - if ($previousSchoolYear === null) { - return []; - } $messages = []; foreach (array_unique($studentIds) as $studentId) { $studentId = (int) $studentId; - $decisionRow = $this->studentDecisionForYear($studentId, $previousSchoolYear); - $decision = strtolower(trim((string) ($decisionRow['decision'] ?? ''))); - $source = strtolower(trim((string) ($decisionRow['source'] ?? ''))); - - if ($decision === 'pass' || $decision === 'repeat class') { + if ($studentId <= 0) { continue; } - if ($decision === '' && $source !== 'pending') { + if ($previousSchoolYear === null) { + $messages[] = 'Student ID ' . $studentId . ': enrollment cannot be submitted because the closing school year could not be determined.'; continue; } - $studentInfo = $this->studentModel->getFullNameById($studentId); - $studentName = is_array($studentInfo) - ? trim((string) ($studentInfo['firstname'] ?? '') . ' ' . (string) ($studentInfo['lastname'] ?? '')) - : trim((string) $studentInfo); - $studentName = $studentName !== '' ? $studentName : 'Student ID ' . $studentId; - - $messages[] = $studentName . ': enrollment cannot be submitted until the prior-year decision is resolved with administration.'; + try { + $studentName = $this->studentNameForEnrollmentMessage($studentId); + $evaluation = service('enrollmentTransition')->evaluate($studentId, $previousSchoolYear, $targetSchoolYear, 'parent'); + foreach ($evaluation['blockers'] ?? [] as $blocker) { + $blocker = trim((string) $blocker); + if ($blocker !== '') { + $messages[] = $this->messageWithStudentName($studentName, $blocker); + } + } + } catch (\Throwable $e) { + log_message('error', 'Enrollment transition evaluation failed: ' . $e->getMessage()); + $messages[] = 'Student ID ' . $studentId . ': enrollment eligibility could not be evaluated. Please contact the administration.'; + } } return $messages; } + private function enrollmentEligibilityMessageForStudent( + array $student, + ?array $decisionRow, + string $targetSchoolYear, + ?string $fallMakeupExamOn = null + ): array { + return EnrollmentEligibility::parentDecisionMessage($student, $decisionRow, $targetSchoolYear, $fallMakeupExamOn); + } + + private function transitionEvaluationForStudent(int $studentId, string $previousSchoolYear, string $selectedYear): ?array + { + try { + return service('enrollmentTransition')->evaluate($studentId, $previousSchoolYear, $selectedYear, 'parent'); + } catch (\Throwable $e) { + log_message('error', 'Enrollment transition evaluation failed for student ' . $studentId . ': ' . $e->getMessage()); + + return [ + 'blockers' => ['Enrollment eligibility could not be evaluated. Please contact the administration.'], + 'warnings' => [], + 'placement_status' => 'unknown', + 'deliberation_decision' => null, + 'parent_enrollment_allowed' => false, + 'student_self_enrollment_allowed' => false, + 'assigned_grade_id' => null, + 'assigned_class_section_id' => null, + ]; + } + } + + private function readonlyEnrollmentEvaluation(?array $decisionRow): array + { + $decision = DeliberationDecision::normalize($decisionRow['deliberation_decision_standard'] ?? null) + ?? DeliberationDecision::normalize($decisionRow['decision'] ?? null); + + return [ + 'blockers' => [], + 'warnings' => [], + 'placement_status' => 'readonly', + 'deliberation_decision' => $decision, + 'decision_label' => DeliberationDecision::display($decisionRow['decision'] ?? '') ?: 'Pending', + 'parent_enrollment_allowed' => false, + 'student_self_enrollment_allowed' => false, + 'assigned_grade_id' => null, + 'assigned_class_section_id' => null, + ]; + } + + private function eligibilityMessageFromTransition(array $student, ?array $evaluation, ?string $fallMakeupExamOn): array + { + if ($evaluation === null) { + return $this->enrollmentEligibilityMessageForStudent( + $student, + $student['previous_year_decision'] ?? null, + $this->currentSchoolYearName((string) ($this->schoolYear ?? '')), + $fallMakeupExamOn + ); + } + + $blockers = array_values(array_filter(array_map('trim', array_map('strval', $evaluation['blockers'] ?? [])))); + if ($blockers !== []) { + $name = $this->studentNameFromRow($student); + $blockers = array_map(fn(string $message): string => $this->messageWithStudentName($name, $message), $blockers); + + return [ + 'message' => implode(' ', $blockers), + 'blocking' => true, + 'level' => 'danger', + ]; + } + + $warnings = array_values(array_filter(array_map('trim', array_map('strval', $evaluation['warnings'] ?? [])))); + if ($warnings !== []) { + return [ + 'message' => implode(' ', $warnings), + 'blocking' => false, + 'level' => 'warning', + ]; + } + + $decision = (string) ($evaluation['deliberation_decision'] ?? ''); + if ($decision === DeliberationDecision::MAKE_UP_EXAM) { + $name = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')); + $dateText = $fallMakeupExamOn !== null ? ' on ' . local_date($fallMakeupExamOn, 'm-d-Y') : ''; + return [ + 'message' => ($name !== '' ? $name : 'The student') . ' may complete enrollment now and will initially remain in the same grade until the make-up exam' . $dateText . ' is resolved by administration.', + 'blocking' => false, + 'level' => 'warning', + ]; + } + + return ['message' => '', 'blocking' => false, 'level' => 'info']; + } + + private function studentNameFromRow(array $student): string + { + $name = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')); + + return $name !== '' ? $name : 'The student'; + } + + private function studentNameForEnrollmentMessage(int $studentId): string + { + try { + $row = $this->db->table('students') + ->select('firstname, lastname') + ->where('id', $studentId) + ->limit(1) + ->get() + ->getRowArray(); + + return is_array($row) ? $this->studentNameFromRow($row) : 'Student ID ' . $studentId; + } catch (\Throwable $e) { + log_message('error', 'Unable to load student name for enrollment blocker: ' . $e->getMessage()); + + return 'Student ID ' . $studentId; + } + } + + private function messageWithStudentName(string $studentName, string $message): string + { + $studentName = trim($studentName) !== '' ? trim($studentName) : 'The student'; + $message = trim($message); + + if ($message === '' || str_contains($message, $studentName)) { + return $message; + } + + return $studentName . ': ' . $message; + } + + private function expectedPlacementLabel(?array $evaluation): string + { + if ($evaluation === null || ($evaluation['blockers'] ?? []) !== [] && empty($evaluation['assigned_grade_id'])) { + return 'Pending'; + } + + $status = (string) ($evaluation['placement_status'] ?? ''); + return match ($status) { + 'automatic_distribution_pending' => $this->gradeLabel($this->classNameById((int) ($evaluation['assigned_grade_id'] ?? 0))), + 'same_class_assigned', 'temporary_same_grade' => $this->classSectionNameById((int) ($evaluation['assigned_class_section_id'] ?? 0)) ?: 'Same grade', + 'manual_class_required' => 'Same grade - administration must assign class', + 'temporary_manual_class_required' => 'Same grade initially - administration must assign temporary class', + 'exit_required' => 'Completion or exit process required', + default => 'Pending', + }; + } + + private function requiredActionLabel(?array $evaluation): string + { + if ($evaluation === null) { + return 'Complete re-enrollment before the registration deadline.'; + } + + 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.'; + } + + $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.'; + } + + if (($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM) { + return 'Complete re-enrollment and follow make-up exam instructions.'; + } + + return 'Complete re-enrollment before the registration deadline.'; + } + + private function familyFinancialSummary(int $parentId, ?string $previousSchoolYear, string $selectedYear): array + { + $schoolYearConfig = $this->schoolYearConfig($selectedYear); + $carryOver = $previousSchoolYear !== null ? $this->invoiceBalanceForParent($parentId, $previousSchoolYear) : 0.0; + $currentBalance = $this->invoiceBalanceForParent($parentId, $selectedYear); + $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'); + $amountDue = max(0.0, $carryOver) + $registrationFee + $tuitionDue + $mandatoryFees; + + return [ + 'currency' => '$', + 'carry_over_balance' => round($carryOver, 2), + 'current_balance' => round($currentBalance, 2), + 'registration_fee' => $registrationFee, + 'tuition_due_at_registration' => $tuitionDue, + 'mandatory_fees' => $mandatoryFees, + 'amount_due' => round($amountDue, 2), + 'balance_behavior' => $behavior, + 'payment_plan_available' => (bool) ($schoolYearConfig['payment_plan_available'] ?? false), + 'policy_message' => $this->financialPolicyMessage($behavior, (string) ($schoolYearConfig['financial_policy_message'] ?? '')), + ]; + } + + 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); + if ($configured !== '') { + return $configured; + } + + return match ($behavior) { + 'payment_plan_required' => 'Registration may continue under an approved payment plan.', + '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.', + }; + } + + private function invoiceBalanceForParent(int $parentId, string $schoolYear): float + { + if ($parentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('invoices')) { + return 0.0; + } + + $row = $this->db->table('invoices') + ->select('COALESCE(SUM(balance), 0) AS balance', false) + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->get() + ->getRowArray(); + + return round((float) ($row['balance'] ?? 0), 2); + } + + private function schoolYearConfig(string $schoolYear): array + { + if ($schoolYear === '' || ! $this->db->tableExists('school_years')) { + return []; + } + + return $this->db->table('school_years') + ->where('name', $schoolYear) + ->limit(1) + ->get() + ->getRowArray() ?: []; + } + + private function classNameById(int $classId): string + { + if ($classId <= 0 || ! $this->db->tableExists('classes')) { + return 'Assigned grade'; + } + + $row = $this->db->table('classes') + ->select('class_name') + ->where('id', $classId) + ->limit(1) + ->get() + ->getRowArray(); + + return trim((string) ($row['class_name'] ?? '')) ?: 'Assigned grade'; + } + + private function gradeLabel(string $className): string + { + $className = trim($className); + if ($className === '') { + return 'Assigned grade'; + } + + return preg_match('/^grade\b/i', $className) === 1 ? $className : 'Grade ' . $className; + } + private function previousSchoolYearName(string $schoolYear): ?string { $schoolYear = trim($schoolYear); @@ -1408,8 +2100,10 @@ class ParentController extends BaseController log_message('error', "Failed to fetch registration data: " . $e->getMessage()); return redirect()->back()->with('error', 'Failed to retrieve registration info.'); } + $selectedSchoolYear = (string) ($data['selectedYear'] ?? ''); $data['lastDayOfRegistration'] = $this->lastDayOfRegistration; - $data['registrationAgeDeadline'] = $this->dateAgeReference; + $data['registrationAgeDeadline'] = $this->registrationMinimumAgeDeadline($selectedSchoolYear); + $data['schoolYearAgeDeadline'] = $this->schoolYearAgeDeadline($selectedSchoolYear); return view('parent/register_student', $data); } @@ -1500,15 +2194,21 @@ class ParentController extends BaseController $this->validateNames($lastName); $dobObj = new \DateTime($dob); - $objregistrationAgeDeadline = new \DateTime($this->ageDateRefernce); - $age = $dobObj->diff($objregistrationAgeDeadline)->y; + $schoolYearAgeDeadline = $this->schoolYearAgeDeadline($schoolYear); + $age = $this->calculateAgeAsOfSchoolYearStartYear($dob, $schoolYear); - $validation = $this->validateDobAge($dob, $this->ageDateRefernce); + $validation = $this->validateDobAge( + $dob, + $this->registrationMinimumAgeDeadline($schoolYear), + 5, + 18, + $schoolYearAgeDeadline + ); if (!$validation['isValid']) { - $displayDeadline = (new DateTime($this->ageDateRefernce))->format('m-d-Y'); + $displayDeadline = (new DateTime($schoolYearAgeDeadline))->format('m-d-Y'); session()->setFlashdata( 'error', - "Student '{$firstName} {$lastName}' {$validation['message']}. Age at deadline would be: $displayDeadline." + "Student '{$firstName} {$lastName}' {$validation['message']}. General age is calculated as of {$displayDeadline}." ); return false; } @@ -1613,7 +2313,13 @@ $existing = $this->studentModel * @param int $maxAge Maximum allowed age (default: 18) * @return array ['isValid' => bool, 'message' => string] */ - function validateDobAge(string $dob, string $registrationAgeDeadline, int $minAge = 5, int $maxAge = 18): array + function validateDobAge( + string $dob, + string $registrationAgeDeadline, + int $minAge = 5, + int $maxAge = 18, + ?string $schoolYearAgeDeadline = null + ): array { $response = ['isValid' => false, 'message' => '', 'age' => null]; @@ -1635,24 +2341,32 @@ $existing = $this->studentModel return $response; } - // 3) Parse deadline; if it's date-only, we set it to end-of-day to be inclusive + // 3) Parse deadlines; Dec 31 is only the special minimum-age registration grace date. try { - $deadline = new DateTimeImmutable($registrationAgeDeadline, $tz); + $minimumAgeDeadline = new DateTimeImmutable($registrationAgeDeadline, $tz); } catch (Throwable $e) { - $deadline = new DateTimeImmutable('now', $tz); + $minimumAgeDeadline = new DateTimeImmutable('now', $tz); } - // Inclusive of the deadline date - $deadline = $deadline->setTime(23, 59, 59); - // 4) Age at deadline (now that times are normalized) - $ageAtDeadline = $birthDate->diff($deadline)->y; + try { + $ageDeadline = new DateTimeImmutable($schoolYearAgeDeadline ?: $registrationAgeDeadline, $tz); + } catch (Throwable $e) { + $ageDeadline = $minimumAgeDeadline; + } + + $minimumAgeDeadline = $minimumAgeDeadline->setTime(23, 59, 59); + $ageDeadline = $ageDeadline->setTime(23, 59, 59); + + // 4) The persisted/general age is based on Sep 1 of the school year. + $ageAtDeadline = $birthDate->diff($ageDeadline)->y; + $ageAtMinimumAgeDeadline = $birthDate->diff($minimumAgeDeadline)->y; $response['age'] = $ageAtDeadline; // 5) Allowed birthdate window (inclusive) - // - Earliest birthdate = deadline - maxAge years (exactly maxAge on deadline is OK) - // - Latest birthdate = deadline - minAge years (exactly minAge on deadline is OK) - $minBirthDate = $deadline->modify("-{$maxAge} years")->setTime(0, 0, 0); // earliest allowed - $maxBirthDate = $deadline->modify("-{$minAge} years")->setTime(23, 59, 59); // latest allowed + // - Earliest birthdate = Sep 1 - maxAge years. + // - Latest birthdate = Dec 31 - minAge years, only for minimum-age registration eligibility. + $minBirthDate = $ageDeadline->modify('-' . ($maxAge + 1) . ' years')->modify('+1 day')->setTime(0, 0, 0); // earliest allowed + $maxBirthDate = $minimumAgeDeadline->modify("-{$minAge} years")->setTime(23, 59, 59); // youngest allowed // 6) Validate $isValid = ($birthDate >= $minBirthDate) && ($birthDate <= $maxBirthDate); @@ -1660,11 +2374,12 @@ $existing = $this->studentModel if (!$isValid) { $response['message'] = sprintf( - 'Must be %d-%d years old by %s. Current age would be: %d', + 'Must be at least %d years old by %s and no older than %d by %s. Current registration age would be: %d', $minAge, + $minimumAgeDeadline->format('m-d-Y'), $maxAge, - $deadline->format('m-d-Y'), - $ageAtDeadline + $ageDeadline->format('m-d-Y'), + $ageAtMinimumAgeDeadline ); } @@ -2031,9 +2746,9 @@ $existing = $this->studentModel { try { $parts = explode('-', $this->schoolYear); - $endYear = isset($parts[1]) ? trim($parts[1]) : date('Y'); + $startYear = isset($parts[0]) ? trim($parts[0]) : date('Y'); - $registrationAgeDeadline = "$endYear-12-31"; + $registrationAgeDeadline = "$startYear-09-01"; // Convert both dates into DateTime objects $deadlineObj = \DateTime::createFromFormat('Y-m-d', $registrationAgeDeadline); @@ -2062,41 +2777,6 @@ $existing = $this->studentModel } } - private function calculateAgeAsOfToday(?string $dob): ?int - { - $dob = trim((string) $dob); - - if ($dob === '') { - return null; - } - - try { - $timezone = new \DateTimeZone((string) (config('School')->attendance['timezone'] ?? user_timezone())); - $birthDate = \DateTimeImmutable::createFromFormat('!Y-m-d', $dob, $timezone); - $errors = \DateTimeImmutable::getLastErrors(); - $hasParseErrors = is_array($errors) - && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0); - - if ($birthDate === false || $hasParseErrors) { - return null; - } - - $today = new \DateTimeImmutable('today', $timezone); - - if ($birthDate > $today) { - return null; - } - - return $birthDate->diff($today)->y; - } catch (\Throwable $e) { - log_message('warning', 'Unable to calculate current age from DOB: {message}', [ - 'message' => $e->getMessage(), - ]); - - return null; - } - } - private function calculateAgeAsOfSchoolYearStartYear(?string $dob, string $schoolYear): ?int { $dob = trim((string) $dob); @@ -2117,7 +2797,7 @@ $existing = $this->studentModel return null; } - $schoolYearStartYearCutoff = new \DateTimeImmutable($matches[1] . '-12-31', $timezone); + $schoolYearStartYearCutoff = new \DateTimeImmutable($matches[1] . '-09-01', $timezone); if ($birthDate > $schoolYearStartYearCutoff) { return null; @@ -2133,6 +2813,33 @@ $existing = $this->studentModel } } + private function schoolYearAgeDeadline(string $schoolYear): string + { + if (preg_match('/^(\d{4})/', trim($schoolYear), $matches)) { + return $matches[1] . '-09-01'; + } + + if (!empty($this->schoolStartDate) && strtotime((string) $this->schoolStartDate)) { + return (new \DateTimeImmutable((string) $this->schoolStartDate))->format('Y-m-d'); + } + + return date('Y') . '-09-01'; + } + + private function registrationMinimumAgeDeadline(string $schoolYear): string + { + $configured = trim((string) ($this->ageDateRefernce ?? $this->dateAgeReference ?? '')); + if ($configured !== '' && strtotime($configured)) { + return (new \DateTimeImmutable($configured))->format('Y-m-d'); + } + + if (preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches)) { + return $matches[2] . '-12-31'; + } + + return date('Y') . '-12-31'; + } + public function parentEventPage() { $schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); diff --git a/app/Controllers/View/PaymentController.php b/app/Controllers/View/PaymentController.php index 4c21f2c..41ac88d 100644 --- a/app/Controllers/View/PaymentController.php +++ b/app/Controllers/View/PaymentController.php @@ -151,6 +151,13 @@ class PaymentController extends ResourceController } } + private function assertSchoolYearNameWritable(string $schoolYear): void + { + service('schoolYearWriteGuard')->assertWritable( + service('schoolYearContext')->forYearName($schoolYear) + ); + } + // View: Create a new payment plan public function create() { @@ -160,6 +167,12 @@ class PaymentController extends ResourceController // API: Update balance after payment public function updateBalanceAPI($paymentId) { + $payment = $this->paymentModel->find($paymentId); + if (!$payment) { + return $this->failNotFound('Payment not found.'); + } + $this->assertSchoolYearNameWritable((string)($payment['school_year'] ?? '')); + $amountPaid = $this->request->getPost('paid_amount'); if ($this->paymentModel->updateBalance($paymentId, $amountPaid)) { return $this->respond(['status' => 'success']); @@ -171,6 +184,12 @@ class PaymentController extends ResourceController // View: Update balance after payment public function updateBalance($paymentId) { + $payment = $this->paymentModel->find($paymentId); + if (!$payment) { + return redirect()->back()->with('error', 'Payment not found.'); + } + $this->assertSchoolYearNameWritable((string)($payment['school_year'] ?? '')); + $amountPaid = $this->request->getPost('paid_amount'); if ($this->paymentModel->updateBalance($paymentId, $amountPaid)) { return redirect()->to('/payments'); @@ -879,6 +898,7 @@ class PaymentController extends ResourceController if (!$invoice) { return redirect()->back()->with('error', 'Invoice not found.'); } + $this->assertSchoolYearNameWritable((string)($invoice['school_year'] ?? '')); // Snapshot pre-payment balance $initialPreBalance = $this->getCurrentInvoiceBalance($invoiceId); @@ -1142,6 +1162,7 @@ class PaymentController extends ResourceController if (!$invoice) { return redirect()->back()->with('error', 'Linked invoice not found.'); } + $this->assertSchoolYearNameWritable((string)($invoice['school_year'] ?? ($payment['school_year'] ?? ''))); //$schoolYear = (new ConfigurationModel())->getConfig('school_year'); $checkFile = $payment['check_file']; // default keep old file @@ -1280,6 +1301,7 @@ class PaymentController extends ResourceController } $schoolYear = (string)($invoice['school_year'] ?? $this->schoolYear); + $this->assertSchoolYearNameWritable($schoolYear); $this->recalculateInvoice((int)$invoice['id'], $schoolYear); return redirect()->back()->with('success', 'Invoice recalculated.'); diff --git a/app/Controllers/View/ReimbursementController.php b/app/Controllers/View/ReimbursementController.php index 0f64a68..686dbde 100644 --- a/app/Controllers/View/ReimbursementController.php +++ b/app/Controllers/View/ReimbursementController.php @@ -488,6 +488,7 @@ class ReimbursementController extends BaseController 'error' => 'Expense not found.', ]); } + $this->assertSchoolYearNameWritable((string)($expense['school_year'] ?? '')); if (!empty($expense['reimbursement_id'])) { return $this->response->setStatusCode(409)->setJSON([ @@ -655,6 +656,12 @@ public function updateBatchAssignment() $this->db->transBegin(); try { + $expense = $this->expenseModel->find($expenseId); + if (!$expense) { + throw new \RuntimeException('Expense not found.'); + } + $this->assertSchoolYearNameWritable((string)($expense['school_year'] ?? '')); + $activeItem = $this->batchItemModel ->where('expense_id', $expenseId) ->where('unassigned_at IS NULL', null, false) @@ -698,6 +705,7 @@ public function updateBatchAssignment() 'error' => 'Batch not found or already closed.', ]); } + $this->assertSchoolYearNameWritable((string)($batch['school_year'] ?? '')); if (!$reimbursementId) { if ($activeItem && !empty($activeItem['reimbursement_id'])) { @@ -847,6 +855,7 @@ public function updateBatchAssignment() 'error' => 'Batch not found or already closed.', ]); } + $this->assertSchoolYearNameWritable((string)($batch['school_year'] ?? '')); $adminRows = $this->batchItemModel ->select('DISTINCT COALESCE(admin_id, 0) AS admin_id') @@ -1038,6 +1047,7 @@ public function updateBatchAssignment() 'error' => 'Batch not found.', ]); } + $this->assertSchoolYearNameWritable((string)($batch['school_year'] ?? '')); if (strtolower((string) ($batch['status'] ?? '')) !== 'open') { return $this->response->setStatusCode(409)->setJSON([ 'success' => false, @@ -1618,6 +1628,7 @@ public function updateBatchAssignment() 'error' => 'Requested batch was not found.', ]); } + $this->assertSchoolYearNameWritable((string)($batch['school_year'] ?? '')); $receiptRows = !empty($receiptIds) ? $this->fetchBatchReceiptRows($batchId, $receiptIds) : []; @@ -2108,6 +2119,7 @@ public function updateBatchAssignment() if (!$reimb) { throw PageNotFoundException::forPageNotFound("Reimbursement #$id not found"); } + $this->assertSchoolYearNameWritable((string)($reimb['school_year'] ?? '')); if ($this->isPaidReimbursement($reimb)) { return redirect()->to('/reimbursements')->with('error', 'Paid reimbursements are immutable. Reverse and replace the transaction instead.'); } @@ -2207,6 +2219,7 @@ public function updateBatchAssignment() if (!$reimbursement) { throw new \RuntimeException('Reimbursement not found.'); } + $this->assertSchoolYearNameWritable((string)($reimbursement['school_year'] ?? '')); if (FinancialStatus::normalizeReimbursementStatus($reimbursement['status'] ?? null) !== FinancialStatus::REIMBURSEMENT_PAID) { throw new \RuntimeException('Only paid reimbursements can be reversed.'); } @@ -2277,6 +2290,7 @@ public function updateBatchAssignment() if (!$expense) { throw new \RuntimeException('Expense not found.'); } + $this->assertSchoolYearNameWritable((string)($expense['school_year'] ?? '')); if (FinancialStatus::normalize((string) ($expense['status'] ?? '')) !== 'approved') { throw new \RuntimeException('Expense must be approved before reimbursement.'); } diff --git a/app/Controllers/View/StudentController.php b/app/Controllers/View/StudentController.php index ea8b025..81b5e92 100644 --- a/app/Controllers/View/StudentController.php +++ b/app/Controllers/View/StudentController.php @@ -13,6 +13,7 @@ use App\Models\ConfigurationModel; use App\Models\StudentSectionDistributionDraftModel; use App\Models\StudentAllergyModel; use App\Models\StudentMedicalConditionModel; +use App\Support\Enrollment\DeliberationDecision; use CodeIgniter\Database\Exceptions\DataException; use Config\Services; use Throwable; @@ -33,6 +34,9 @@ class StudentController extends BaseController protected $classSectionModel; protected $emergencyContact; protected $enrollmentModel; + protected $distributionBaseClassIdCache = []; + protected $distributionPreviousClassSectionCache = []; + protected $distributionExcludedDecisionCache = []; public function __construct() { @@ -890,6 +894,7 @@ class StudentController extends BaseController $maxRaw = trim((string) ($this->request->getPost('max_students_per_section') ?? '')); $maxPerSection = $maxRaw === '' ? null : (int) $maxRaw; $year = trim((string) ($this->request->getPost('school_year') ?? $this->schoolYear)); + $this->ensureClassSectionsForYear($year); if ($classId <= 0 && $classSectionId > 0) { $cid = $this->classSectionModel->getClassId($classSectionId); @@ -938,6 +943,7 @@ class StudentController extends BaseController $updatedBy = (int)(session()->get('user_id') ?? 0) ?: null; $now = utc_now(); $batchKey = sha1($year . ':' . $classId . ':' . microtime(true)); + $draftIdByStudentId = []; $this->db->transStart(); $studentIdsToReplace = array_values(array_unique(array_map( @@ -954,7 +960,7 @@ class StudentController extends BaseController $secId = (int)$b['class_section_id']; foreach ($b['assigned'] as $student) { $sid = (int)$student['student_id']; - $draftModel->insert([ + $draftId = (int)$draftModel->insert([ 'student_id' => $sid, 'class_id' => $classId, 'class_section_id' => $secId, @@ -968,8 +974,12 @@ class StudentController extends BaseController 'created_at' => $now, 'updated_at' => $now, ]); + if ($draftId > 0) { + $draftIdByStudentId[$sid] = $draftId; + } if ((int)($student['promotion_queue_id'] ?? 0) > 0) { $promo->update((int)$student['promotion_queue_id'], [ + 'to_class_id' => $classId, 'to_class_section_id' => $secId, 'status' => 'assigned', 'updated_by' => $updatedBy, @@ -998,17 +1008,35 @@ class StudentController extends BaseController $male = 0; $female = 0; $studentNames = []; + $studentAssignments = []; foreach ($b['assigned'] as $student) { $groups[$student['score_group']] = ($groups[$student['score_group']] ?? 0) + 1; $gender = strtolower((string)($student['gender'] ?? '')); if ($gender === 'female') $female++; else $male++; + $studentId = (int)($student['student_id'] ?? 0); $studentName = trim((string)($student['student_name'] ?? '')); if ($studentName === '') { - $studentName = 'Student #' . (int)($student['student_id'] ?? 0); + $studentName = 'Student #' . $studentId; } $studentNames[] = $studentName; + $studentAssignments[] = [ + 'draft_id' => $draftIdByStudentId[$studentId] ?? 0, + 'student_id' => $studentId, + 'student_name' => $studentName, + 'age_at_reference' => $student['age_at_reference'] ?? null, + 'gender' => (string)($student['gender'] ?? ''), + 'last_year_class_section' => (string)($student['last_year_class_section'] ?? ''), + 'class_id' => $classId, + 'class_section_id' => $secId, + 'class_section_name' => $nameById[$secId] ?? (string)$secId, + ]; } + usort($studentNames, static fn($a, $b): int => strnatcasecmp((string)$a, (string)$b)); + usort($studentAssignments, static function (array $a, array $b): int { + return strnatcasecmp((string)($a['student_name'] ?? ''), (string)($b['student_name'] ?? '')); + }); $summary[] = [ + 'class_id' => $classId, 'class_section_id' => $secId, 'class_section_name' => $nameById[$secId] ?? (string)$secId, 'total' => count($b['assigned']), @@ -1017,6 +1045,7 @@ class StudentController extends BaseController 'score_groups' => $groups, 'average_score' => count($scores) > 0 ? round(array_sum($scores) / count($scores), 2) : null, 'student_names' => $studentNames, + 'student_assignments'=> $studentAssignments, ]; } @@ -1029,25 +1058,215 @@ class StudentController extends BaseController } } + public function updateDistributionDraft() + { + $json = function (array $p, int $code = 200) { + $p['csrfTokenName'] = csrf_token(); + $p['csrfHash'] = csrf_hash(); + $p[csrf_token()] = csrf_hash(); + return $this->response->setStatusCode($code)->setJSON($p); + }; + + try { + $draftId = (int)$this->request->getPost('draft_id'); + $targetSectionId = (int)$this->request->getPost('class_section_id'); + if ($draftId <= 0 || $targetSectionId <= 0) { + return $json(['ok' => false, 'message' => 'Select a valid student draft and target section.'], 400); + } + + $draftModel = new StudentSectionDistributionDraftModel(); + $draft = $draftModel->where('id', $draftId) + ->where('status', 'pending') + ->first(); + if (!$draft) { + return $json(['ok' => false, 'message' => 'This draft assignment is no longer editable.'], 404); + } + + $year = (string)($draft['school_year'] ?? ''); + $targetSection = $this->sectionForDistribution($targetSectionId, $year); + if (!$targetSection) { + return $json(['ok' => false, 'message' => 'Target class or section must be valid for the selected school year.'], 400); + } + $targetClassId = (int)($targetSection['class_id'] ?? 0); + + $updatedBy = (int)(session()->get('user_id') ?? 0) ?: null; + $now = utc_now(); + + $this->db->transStart(); + $draftModel->update($draftId, [ + 'class_id' => $targetClassId, + 'class_section_id' => $targetSectionId, + 'updated_at' => $now, + ]); + + if ($this->db->tableExists('promotion_queue')) { + $this->db->table('promotion_queue') + ->where('student_id', (int)($draft['student_id'] ?? 0)) + ->where('school_year_to', $year) + ->whereIn('status', ['queued', 'assigned']) + ->update([ + 'to_class_id' => $targetClassId, + 'to_class_section_id' => $targetSectionId, + 'status' => 'assigned', + 'updated_by' => $updatedBy, + 'updated_at' => $now, + ]); + } + $this->db->transComplete(); + + if (!$this->db->transStatus()) { + return $json(['ok' => false, 'message' => 'Draft assignment could not be updated.'], 500); + } + + return $json(['ok' => true, 'message' => 'Draft assignment updated.']); + } catch (\Throwable $e) { + return $json(['ok' => false, 'message' => 'Draft assignment update failed: ' . $e->getMessage()], 500); + } + } + + public function updateDistributionCandidate() + { + $json = function (array $p, int $code = 200) { + $p['csrfTokenName'] = csrf_token(); + $p['csrfHash'] = csrf_hash(); + $p[csrf_token()] = csrf_hash(); + return $this->response->setStatusCode($code)->setJSON($p); + }; + + try { + $studentId = (int)$this->request->getPost('student_id'); + $targetSectionId = (int)$this->request->getPost('class_section_id'); + $year = trim((string)($this->request->getPost('school_year') ?? $this->schoolYear)); + + if ($studentId <= 0 || $targetSectionId <= 0 || $year === '') { + return $json(['ok' => false, 'message' => 'Select a valid student, year, and target assignment.'], 400); + } + + $targetSection = $this->sectionForDistribution($targetSectionId, $year); + if (!$targetSection) { + return $json(['ok' => false, 'message' => 'Target class or section must be valid for the selected school year.'], 400); + } + $targetClassId = (int)($targetSection['class_id'] ?? 0); + if ($targetClassId <= 0) { + return $json(['ok' => false, 'message' => 'Target class could not be resolved.'], 400); + } + + $queueRow = null; + if ($this->db->tableExists('promotion_queue')) { + $queueRow = $this->db->table('promotion_queue') + ->where('student_id', $studentId) + ->where('school_year_to', $year) + ->whereIn('status', ['queued', 'assigned']) + ->orderBy('id', 'DESC') + ->get() + ->getRowArray(); + } + + $previousSchoolYear = (string)($queueRow['school_year_from'] ?? ($this->previousSchoolYearName($year) ?? '')); + $previousScore = $this->previousAverageScore($studentId, $previousSchoolYear); + if ($previousScore === null && $this->db->tableExists('student_decisions')) { + $decisionRow = $this->db->table('student_decisions') + ->select('year_score') + ->where('student_id', $studentId) + ->where('school_year', $previousSchoolYear) + ->orderBy('updated_at', 'DESC') + ->orderBy('id', 'DESC') + ->get() + ->getRowArray(); + $previousScore = is_numeric($decisionRow['year_score'] ?? null) ? (float)$decisionRow['year_score'] : null; + } + $previousScore = $previousScore === null ? 0.0 : max(0.0, min(100.0, (float)$previousScore)); + + $draftModel = new StudentSectionDistributionDraftModel(); + $updatedBy = (int)(session()->get('user_id') ?? 0) ?: null; + $now = utc_now(); + + $this->db->transStart(); + $draftModel->where('student_id', $studentId) + ->where('school_year', $year) + ->where('status', 'pending') + ->delete(); + + $draftId = (int)$draftModel->insert([ + 'student_id' => $studentId, + 'class_id' => $targetClassId, + 'class_section_id' => $targetSectionId, + 'school_year' => $year, + 'previous_school_year' => $previousSchoolYear, + 'previous_final_score' => $previousScore, + 'score_group' => $this->scoreGroup($previousScore), + 'status' => 'pending', + 'batch_key' => sha1($year . ':' . $studentId . ':' . microtime(true)), + 'created_by' => $updatedBy, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + if ($this->db->tableExists('promotion_queue')) { + $this->db->table('promotion_queue') + ->where('student_id', $studentId) + ->where('school_year_to', $year) + ->whereIn('status', ['queued', 'assigned']) + ->update([ + 'to_class_id' => $targetClassId, + 'to_class_section_id' => $targetSectionId, + 'status' => 'assigned', + 'updated_by' => $updatedBy, + 'updated_at' => $now, + ]); + } + $this->db->transComplete(); + + if (!$this->db->transStatus() || $draftId <= 0) { + return $json(['ok' => false, 'message' => 'Candidate assignment could not be saved.'], 500); + } + + return $json(['ok' => true, 'message' => 'Candidate assignment saved.']); + } catch (\Throwable $e) { + return $json(['ok' => false, 'message' => 'Candidate assignment update failed: ' . $e->getMessage()], 500); + } + } + private function distributionCandidates(int $classId, string $year): array { - $rows = $this->db->table('promotion_queue pq') - ->select('pq.id AS promotion_queue_id, pq.student_id, pq.school_year_from, pq.to_class_id, students.firstname, students.lastname, students.gender, sd.year_score AS decision_score') + if ($this->isDistributionKgClass($classId, $year)) { + return $this->mergeDistributionCandidates( + $this->kgDistributionCandidates($classId, $year), + $this->currentYearDistributionCandidates($classId, $year) + ); + } + + $builder = $this->db->table('promotion_queue pq') + ->select('pq.id AS promotion_queue_id, pq.student_id, pq.school_year_from, pq.to_class_id, students.firstname, students.lastname, students.gender, students.age, students.dob, sd.year_score AS decision_score') ->join('students', 'students.id = pq.student_id', 'left') ->join('student_decisions sd', 'sd.student_id = pq.student_id AND sd.school_year = pq.school_year_from', 'left') - ->where('pq.to_class_id', $classId) ->where('pq.school_year_to', $year) ->whereIn('pq.status', ['queued', 'assigned']) - ->groupBy('pq.id') + ->groupBy('pq.id'); + $this->applyDistributionAgeFilter($builder, $year); + $rows = $builder ->get() ->getResultArray(); - if (empty($rows)) { - return $this->decisionDistributionCandidates($classId, $year); - } - $out = []; + $excludedByDecision = $this->distributionExcludedDecisionStudentIds($year); foreach ($rows as $row) { + $studentId = (int)($row['student_id'] ?? 0); + if ($studentId <= 0 || isset($excludedByDecision[$studentId])) { + continue; + } + + $ageAtReference = $this->distributionAgeAtReference($row['dob'] ?? null, $year); + $targetClassId = $this->distributionTargetClassIdForStudent( + is_numeric($row['to_class_id'] ?? null) ? (int)$row['to_class_id'] : null, + $ageAtReference, + $year, + (string)($row['source_class_name'] ?? '') + ); + if ($targetClassId !== $classId) { + continue; + } + $score = is_numeric($row['decision_score'] ?? null) ? (float)$row['decision_score'] : $this->previousAverageScore((int)$row['student_id'], (string)($row['school_year_from'] ?? '')); @@ -1055,14 +1274,331 @@ class StudentController extends BaseController $row['previous_final_score'] = $score; $row['score_group'] = $this->scoreGroup($score); $row['student_name'] = $this->formatStudentName($row); + $row['age_at_reference'] = $ageAtReference; + $row['last_year_class_section'] = $this->distributionPreviousClassSectionName( + (int)($row['student_id'] ?? 0), + $year, + (string)($row['school_year_from'] ?? '') + ); $out[] = $row; } + return $this->mergeDistributionCandidates( + $out, + $this->decisionDistributionCandidates($classId, $year), + $this->currentYearDistributionCandidates($classId, $year) + ); + } + + private function currentYearDistributionCandidates(int $classId, string $year): array + { + if ($classId <= 0 || $year === '' || ! $this->db->tableExists('students')) { + return []; + } + + $rows = []; + + if ($this->db->tableExists('student_class')) { + $builder = $this->db->table('student_class sc') + ->select('0 AS promotion_queue_id, sc.student_id, sc.school_year AS school_year_from, cs.class_id AS source_class_id, cs.class_section_name AS source_class_name, students.firstname, students.lastname, students.gender, students.age, students.dob, students.registration_grade', false) + ->join('students', 'students.id = sc.student_id', 'inner') + ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') + ->where('sc.school_year', $year) + ->where('sc.class_section_id IS NOT NULL', null, false); + + if ($this->db->fieldExists('is_event_only', 'student_class')) { + $builder->groupStart() + ->where('sc.is_event_only', 0) + ->orWhere('sc.is_event_only', null) + ->groupEnd(); + } + + if ($this->db->fieldExists('is_active', 'students')) { + $builder->where('students.is_active', 1); + } + + $this->applyDistributionAgeFilter($builder, $year); + $rows = array_merge($rows, $builder->get()->getResultArray()); + } + + if ($this->db->tableExists('enrollments')) { + $builder = $this->db->table('enrollments e') + ->select('0 AS promotion_queue_id, e.student_id, e.school_year AS school_year_from, cs.class_id AS source_class_id, cs.class_section_name AS source_class_name, students.firstname, students.lastname, students.gender, students.age, students.dob, students.registration_grade', false) + ->join('students', 'students.id = e.student_id', 'inner') + ->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left') + ->where('e.school_year', $year) + ->whereIn('e.enrollment_status', ['admission under review', 'payment pending', 'enrolled']) + ->groupStart() + ->where('e.is_withdrawn', 0) + ->orWhere('e.is_withdrawn', null) + ->groupEnd(); + + if ($this->db->fieldExists('is_active', 'students')) { + $builder->where('students.is_active', 1); + } + + $this->applyDistributionAgeFilter($builder, $year); + $rows = array_merge($rows, $builder->get()->getResultArray()); + } + + if ($this->db->fieldExists('school_year', 'students')) { + $builder = $this->db->table('students') + ->select('0 AS promotion_queue_id, id AS student_id, school_year AS school_year_from, NULL AS source_class_id, registration_grade AS source_class_name, firstname, lastname, gender, age, dob, registration_grade', false) + ->where('school_year', $year); + + if ($this->db->fieldExists('is_active', 'students')) { + $builder->where('is_active', 1); + } + + $this->applyDistributionAgeFilter($builder, $year); + $rows = array_merge($rows, $builder->get()->getResultArray()); + } + + $previousYear = $this->previousSchoolYearName($year) ?? ''; + $excludedByDecision = $this->distributionExcludedDecisionStudentIds($year); + $out = []; + foreach ($rows as $row) { + $studentId = (int)($row['student_id'] ?? 0); + if ($studentId <= 0 || isset($excludedByDecision[$studentId])) { + continue; + } + + $ageAtReference = $this->distributionAgeAtReference($row['dob'] ?? null, $year); + $targetClassId = $this->distributionTargetClassIdForStudent( + is_numeric($row['source_class_id'] ?? null) ? (int)$row['source_class_id'] : null, + $ageAtReference, + $year, + (string)($row['source_class_name'] ?? $row['registration_grade'] ?? '') + ); + if ($targetClassId !== $classId) { + continue; + } + + $score = $this->previousAverageScore($studentId, $previousYear); + $score = $score === null ? 0.0 : max(0.0, min(100.0, $score)); + $out[] = [ + 'promotion_queue_id' => 0, + 'student_id' => $studentId, + 'school_year_from' => $previousYear, + 'to_class_id' => $classId, + 'student_name' => $this->formatStudentName($row), + 'age_at_reference' => $ageAtReference, + 'gender' => (string)($row['gender'] ?? ''), + 'last_year_class_section' => $this->distributionPreviousClassSectionName($studentId, $year, $previousYear), + 'previous_final_score' => $score, + 'score_group' => $this->scoreGroup($score), + ]; + } + return $out; } + private function mergeDistributionCandidates(array ...$candidateSets): array + { + $out = []; + $seen = []; + + foreach ($candidateSets as $candidates) { + foreach ($candidates as $candidate) { + $studentId = (int)($candidate['student_id'] ?? 0); + if ($studentId <= 0 || isset($seen[$studentId])) { + continue; + } + + $seen[$studentId] = true; + $out[] = $candidate; + } + } + + return $out; + } + + private function kgDistributionCandidates(int $classId, string $year): array + { + if ($classId <= 0 || $year === '' || ! $this->db->tableExists('enrollments') || ! $this->db->tableExists('students')) { + return []; + } + + $builder = $this->db->table('enrollments e') + ->select('0 AS promotion_queue_id, e.student_id, e.school_year AS school_year_from, cs.class_id AS source_class_id, cs.class_section_name AS source_class_name, students.firstname, students.lastname, students.gender, students.age, students.dob, students.registration_grade', false) + ->join('students', 'students.id = e.student_id', 'inner') + ->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left') + ->where('e.school_year', $year) + ->whereIn('e.enrollment_status', ['admission under review', 'payment pending', 'enrolled']) + ->groupStart() + ->where('e.is_withdrawn', 0) + ->orWhere('e.is_withdrawn', null) + ->groupEnd() + ->groupStart() + ->where('cs.class_id', $classId) + ->orWhereIn('UPPER(students.registration_grade)', ['KG', 'K', 'KINDERGARTEN']) + ->groupEnd(); + + if ($this->db->fieldExists('is_active', 'students')) { + $builder->where('students.is_active', 1); + } + + $this->applyDistributionAgeFilter($builder, $year); + $rows = $builder + ->orderBy('students.lastname', 'ASC') + ->orderBy('students.firstname', 'ASC') + ->get() + ->getResultArray(); + + $seen = []; + $out = []; + $excludedByDecision = $this->distributionExcludedDecisionStudentIds($year); + foreach ($rows as $row) { + $studentId = (int)($row['student_id'] ?? 0); + if ($studentId <= 0 || isset($seen[$studentId]) || isset($excludedByDecision[$studentId])) { + continue; + } + $seen[$studentId] = true; + + $score = $this->previousAverageScore($studentId, $this->previousSchoolYearName($year) ?? ''); + $score = $score === null ? 0.0 : max(0.0, min(100.0, $score)); + $ageAtReference = $this->distributionAgeAtReference($row['dob'] ?? null, $year); + $targetClassId = $this->distributionTargetClassIdForStudent( + is_numeric($row['source_class_id'] ?? null) ? (int)$row['source_class_id'] : $classId, + $ageAtReference, + $year, + (string)($row['source_class_name'] ?? $row['registration_grade'] ?? '') + ); + if ($targetClassId !== $classId) { + continue; + } + + $out[] = [ + 'promotion_queue_id' => 0, + 'student_id' => $studentId, + 'school_year_from' => '', + 'to_class_id' => $classId, + 'student_name' => $this->formatStudentName($row), + 'age_at_reference' => $ageAtReference, + 'gender' => (string)($row['gender'] ?? ''), + 'last_year_class_section' => $this->distributionPreviousClassSectionName($studentId, $year, $this->previousSchoolYearName($year)), + 'previous_final_score' => $score, + 'score_group' => $this->scoreGroup($score), + ]; + } + + foreach ($this->registeredKgDistributionCandidates($classId, $year) as $row) { + $studentId = (int)($row['student_id'] ?? 0); + if ($studentId <= 0 || isset($seen[$studentId])) { + continue; + } + $seen[$studentId] = true; + $out[] = $row; + } + + usort($out, static function (array $a, array $b): int { + return strnatcasecmp((string)($a['student_name'] ?? ''), (string)($b['student_name'] ?? '')); + }); + + return $out; + } + + private function registeredKgDistributionCandidates(int $classId, string $year): array + { + if ($classId <= 0 || $year === '' || ! $this->db->tableExists('students')) { + return []; + } + + $builder = $this->db->table('students') + ->select('id AS student_id, firstname, lastname, gender, age, dob, registration_grade') + ->groupStart() + ->where('UPPER(registration_grade)', 'KG') + ->orWhere('UPPER(registration_grade)', 'K') + ->orWhere('UPPER(registration_grade)', 'KINDERGARTEN') + ->groupEnd(); + + if ($this->db->fieldExists('school_year', 'students')) { + $builder->where('school_year', $year); + } elseif ($this->db->fieldExists('year_of_registration', 'students') && preg_match('/^(\d{4})/', $year, $matches)) { + $builder->where('year_of_registration', (int)$matches[1]); + } + + if ($this->db->fieldExists('is_active', 'students')) { + $builder->where('is_active', 1); + } + + $this->applyDistributionAgeFilter($builder, $year); + $rows = $builder + ->orderBy('lastname', 'ASC') + ->orderBy('firstname', 'ASC') + ->get() + ->getResultArray(); + + $out = []; + $excludedByDecision = $this->distributionExcludedDecisionStudentIds($year); + foreach ($rows as $row) { + $studentId = (int)($row['student_id'] ?? 0); + if ($studentId <= 0 || isset($excludedByDecision[$studentId])) { + continue; + } + + $ageAtReference = $this->distributionAgeAtReference($row['dob'] ?? null, $year); + $targetClassId = $this->distributionTargetClassIdForStudent( + $classId, + $ageAtReference, + $year, + (string)($row['registration_grade'] ?? 'KG') + ); + if ($targetClassId !== $classId) { + continue; + } + + $out[] = [ + 'promotion_queue_id' => 0, + 'student_id' => $studentId, + 'school_year_from' => '', + 'to_class_id' => $classId, + 'student_name' => $this->formatStudentName($row), + 'age_at_reference' => $ageAtReference, + 'gender' => (string)($row['gender'] ?? ''), + 'last_year_class_section' => $this->distributionPreviousClassSectionName($studentId, $year, $this->previousSchoolYearName($year)), + 'previous_final_score' => 0.0, + 'score_group' => $this->scoreGroup(0.0), + ]; + } + + return $out; + } + + private function isDistributionKgClass(int $classId, string $year): bool + { + if ($classId <= 0) { + return false; + } + + $query = $this->classSectionModel + ->select('class_section_name') + ->where('class_id', $classId) + ->where("class_section_name NOT LIKE '%-%'", null, false) + ->orderBy('id', 'DESC'); + if ($year !== '' && $this->db->fieldExists('school_year', 'classSection')) { + $query->where('school_year', $year); + } + + $row = $query->first(); + if (!$row && $year !== '' && $this->db->fieldExists('school_year', 'classSection')) { + $row = $this->classSectionModel + ->select('class_section_name') + ->where('class_id', $classId) + ->where("class_section_name NOT LIKE '%-%'", null, false) + ->orderBy('id', 'DESC') + ->first(); + } + + $name = strtoupper(trim((string)($row['class_section_name'] ?? ''))); + + return in_array($name, ['KG', 'K', 'KINDERGARTEN'], true); + } + private function letterSectionsForDistribution(int $classId, string $year): array { + $this->ensureClassSectionsForYear($year); + $query = $this->classSectionModel ->where('class_id', $classId) ->like('class_section_name', '-', 'both') @@ -1079,6 +1615,80 @@ class StudentController extends BaseController return $this->classSectionModel->getLetterSectionsByClassId($classId); } + private function sectionForDistribution(int $classSectionId, string $year): ?array + { + if ($classSectionId <= 0) { + return null; + } + $this->ensureClassSectionsForYear($year); + + $query = $this->classSectionModel + ->where('class_section_id', $classSectionId) + ->orderBy('id', 'DESC'); + if ($year !== '' && $this->db->fieldExists('school_year', 'classSection')) { + $query->where('school_year', $year); + } + + $section = $query->first(); + if ($section || $year === '' || ! $this->db->fieldExists('school_year', 'classSection')) { + return $section ?: null; + } + + return $this->classSectionModel + ->where('class_section_id', $classSectionId) + ->orderBy('id', 'DESC') + ->first(); + } + + private function ensureClassSectionsForYear(string $targetSchoolYear): void + { + $targetSchoolYear = trim($targetSchoolYear); + if ($targetSchoolYear === '' || ! $this->db->tableExists('classSection') || ! $this->db->fieldExists('school_year', 'classSection')) { + return; + } + + if ($this->db->table('classSection')->where('school_year', $targetSchoolYear)->countAllResults() > 0) { + return; + } + + $sourceSchoolYear = $this->previousSchoolYearName($targetSchoolYear); + if ($sourceSchoolYear === null) { + return; + } + + $sourceRows = $this->db->table('classSection') + ->select('class_id, class_section_id, class_section_name') + ->where('school_year', $sourceSchoolYear) + ->orderBy('id', 'ASC') + ->get() + ->getResultArray(); + + $now = utc_now(); + foreach ($sourceRows as $row) { + $classSectionId = (int)($row['class_section_id'] ?? 0); + if ($classSectionId <= 0) { + continue; + } + + $exists = $this->db->table('classSection') + ->where('school_year', $targetSchoolYear) + ->where('class_section_id', $classSectionId) + ->countAllResults(); + if ($exists > 0) { + continue; + } + + $this->db->table('classSection')->insert([ + 'class_id' => (int)($row['class_id'] ?? 0), + 'class_section_id' => $classSectionId, + 'class_section_name' => (string)($row['class_section_name'] ?? ''), + 'school_year' => $targetSchoolYear, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + private function decisionDistributionCandidates(int $classId, string $targetSchoolYear): array { $previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear); @@ -1086,13 +1696,15 @@ class StudentController extends BaseController return []; } - $rows = $this->db->table('student_decisions sd') - ->select('sd.id AS decision_id, sd.student_id, sd.class_section_name, sd.year_score, sd.decision, students.firstname, students.lastname, students.gender') + $builder = $this->db->table('student_decisions sd') + ->select('sd.id AS decision_id, sd.student_id, sd.class_section_name, sd.year_score, sd.decision, students.firstname, students.lastname, students.gender, students.age, students.dob') ->join('students', 'students.id = sd.student_id', 'left') ->where('sd.school_year', $previousSchoolYear) ->where('students.is_active', 1) ->orderBy('sd.updated_at', 'DESC') - ->orderBy('sd.id', 'DESC') + ->orderBy('sd.id', 'DESC'); + $this->applyDistributionAgeFilter($builder, $targetSchoolYear); + $rows = $builder ->get() ->getResultArray(); @@ -1104,11 +1716,21 @@ class StudentController extends BaseController continue; } $seen[$studentId] = true; + if (DeliberationDecision::normalize($row['decision'] ?? null) !== DeliberationDecision::PASSED) { + continue; + } $targetClassId = $this->targetClassIdFromDecision( (string)($row['class_section_name'] ?? ''), (string)($row['decision'] ?? '') ); + $ageAtReference = $this->distributionAgeAtReference($row['dob'] ?? null, $targetSchoolYear); + $targetClassId = $this->distributionTargetClassIdForStudent( + $targetClassId, + $ageAtReference, + $targetSchoolYear, + (string)($row['class_section_name'] ?? '') + ); if ($targetClassId !== $classId) { continue; } @@ -1121,7 +1743,9 @@ class StudentController extends BaseController 'school_year_from' => $previousSchoolYear, 'to_class_id' => $classId, 'student_name' => $this->formatStudentName($row), + 'age_at_reference' => $ageAtReference, 'gender' => (string)($row['gender'] ?? ''), + 'last_year_class_section' => $this->distributionPreviousClassSectionName($studentId, $targetSchoolYear, $previousSchoolYear), 'previous_final_score' => $score, 'score_group' => $this->scoreGroup($score), ]; @@ -1132,14 +1756,13 @@ class StudentController extends BaseController private function targetClassIdFromDecision(string $classSectionName, string $decision): ?int { - $decision = strtolower(trim($decision)); $baseName = strtoupper(trim(preg_replace('/-.+$/', '', $classSectionName) ?? '')); if ($baseName === '') { return null; } $targetBaseName = $baseName; - if ($decision === 'pass') { + if (DeliberationDecision::normalize($decision) === DeliberationDecision::PASSED) { if ($baseName === 'KG') { $targetBaseName = '1'; } elseif (ctype_digit($baseName)) { @@ -1195,6 +1818,125 @@ class StudentController extends BaseController return is_numeric($row['avg_score'] ?? null) ? (float)$row['avg_score'] : null; } + private function distributionExcludedDecisionStudentIds(string $targetSchoolYear): array + { + $previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear); + if ($previousSchoolYear === null || ! $this->db->tableExists('student_decisions')) { + return []; + } + + if (array_key_exists($previousSchoolYear, $this->distributionExcludedDecisionCache)) { + return $this->distributionExcludedDecisionCache[$previousSchoolYear]; + } + + $rows = $this->db->table('student_decisions') + ->select('student_id, decision') + ->where('school_year', $previousSchoolYear) + ->orderBy('updated_at', 'DESC') + ->orderBy('id', 'DESC') + ->get() + ->getResultArray(); + + $seen = []; + $excluded = []; + foreach ($rows as $row) { + $studentId = (int)($row['student_id'] ?? 0); + if ($studentId <= 0 || isset($seen[$studentId])) { + continue; + } + + $seen[$studentId] = true; + if ($this->isDistributionExcludedDecision((string)($row['decision'] ?? ''))) { + $excluded[$studentId] = true; + } + } + + $this->distributionExcludedDecisionCache[$previousSchoolYear] = $excluded; + + return $excluded; + } + + private function isDistributionExcludedDecision(string $decision): bool + { + return DeliberationDecision::normalize($decision) !== DeliberationDecision::PASSED; + } + + private function distributionPreviousClassSectionName(int $studentId, string $targetSchoolYear, ?string $sourceSchoolYear = null): string + { + if ($studentId <= 0) { + return ''; + } + + $previousYear = trim((string)($sourceSchoolYear ?? '')); + if ($previousYear === '' || $previousYear === $targetSchoolYear || preg_match('/^\d{4}-\d{4}$/', $previousYear) !== 1) { + $previousYear = $this->previousSchoolYearName($targetSchoolYear) ?? ''; + } + if ($previousYear === '') { + return ''; + } + + $cacheKey = $studentId . ':' . $previousYear; + if (array_key_exists($cacheKey, $this->distributionPreviousClassSectionCache)) { + return $this->distributionPreviousClassSectionCache[$cacheKey]; + } + + $names = []; + if ($this->db->tableExists('student_class')) { + $builder = $this->db->table('student_class sc') + ->select('cs.class_section_name') + ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') + ->where('sc.student_id', $studentId) + ->where('sc.school_year', $previousYear) + ->where('sc.class_section_id IS NOT NULL', null, false); + + if ($this->db->fieldExists('is_event_only', 'student_class')) { + $builder->groupStart() + ->where('sc.is_event_only', 0) + ->orWhere('sc.is_event_only', null) + ->groupEnd(); + } + + $rows = $builder + ->orderBy('cs.class_section_name', 'ASC') + ->get() + ->getResultArray(); + foreach ($rows as $row) { + $name = trim((string)($row['class_section_name'] ?? '')); + if ($name !== '') { + $names[$name] = true; + } + } + } + + if (empty($names) && $this->db->tableExists('enrollments')) { + $rows = $this->db->table('enrollments e') + ->select('cs.class_section_name') + ->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left') + ->where('e.student_id', $studentId) + ->where('e.school_year', $previousYear) + ->where('e.class_section_id IS NOT NULL', null, false) + ->whereIn('e.enrollment_status', ['payment pending', 'enrolled']) + ->groupStart() + ->where('e.is_withdrawn', 0) + ->orWhere('e.is_withdrawn', null) + ->groupEnd() + ->orderBy('cs.class_section_name', 'ASC') + ->get() + ->getResultArray(); + foreach ($rows as $row) { + $name = trim((string)($row['class_section_name'] ?? '')); + if ($name !== '') { + $names[$name] = true; + } + } + } + + $value = implode(', ', array_keys($names)); + $this->distributionPreviousClassSectionCache[$cacheKey] = $value; + + return $value; + } + private function scoreGroup(float $score): string { if ($score >= 90) return '90_100'; @@ -1220,60 +1962,90 @@ class StudentController extends BaseController $buckets[$idx] = [ 'class_section_id' => (int)$section['class_section_id'], 'assigned' => [], + 'gender_counts' => ['male' => 0, 'female' => 0, 'other' => 0], ]; } - $groups = ['90_100' => [], '80_89' => [], '70_79' => [], '69_below' => []]; + $genderTotals = ['male' => 0, 'female' => 0, 'other' => 0]; foreach ($students as $student) { - $groups[$student['score_group']][] = $student; + $genderTotals[$this->distributionGenderKey($student)]++; + } + + $targetGenderCounts = []; + foreach ($genderTotals as $gender => $genderTotal) { + $baseGenderSize = intdiv($genderTotal, $sectionCount); + $genderRemainder = $genderTotal % $sectionCount; + $targetGenderCounts[$gender] = array_fill(0, $sectionCount, $baseGenderSize); + $order = range(0, $sectionCount - 1); + if ($gender === 'female') { + $order = array_reverse($order); + } + foreach ($order as $sectionIdx) { + if ($genderRemainder <= 0) { + break; + } + $targetGenderCounts[$gender][$sectionIdx]++; + $genderRemainder--; + } + } + + $groups = [ + '90_100' => ['male' => [], 'female' => [], 'other' => []], + '80_89' => ['male' => [], 'female' => [], 'other' => []], + '70_79' => ['male' => [], 'female' => [], 'other' => []], + '69_below' => ['male' => [], 'female' => [], 'other' => []], + ]; + foreach ($students as $student) { + $scoreGroup = (string)($student['score_group'] ?? '69_below'); + if (!isset($groups[$scoreGroup])) { + $scoreGroup = '69_below'; + } + $groups[$scoreGroup][$this->distributionGenderKey($student)][] = $student; } $currentCounts = array_fill(0, $sectionCount, 0); - $allocations = []; - $groupIndex = 0; - foreach ($groups as $groupName => $groupStudents) { - usort($groupStudents, static fn($a, $b): int => ((float)$b['previous_final_score']) <=> ((float)$a['previous_final_score'])); - $groupTotal = count($groupStudents); - $base = intdiv($groupTotal, $sectionCount); - $extra = $groupTotal % $sectionCount; - $allocations[$groupName] = array_fill(0, $sectionCount, $base); - foreach ($currentCounts as $idx => $cnt) { - $currentCounts[$idx] += $base; - } + $chooseSection = static function (string $gender) use (&$buckets, &$currentCounts, $targetSizes, $targetGenderCounts, $sectionCount): ?int { + $best = null; + $bestScore = null; - $order = range(0, $sectionCount - 1); - $offset = $groupIndex % $sectionCount; - $order = array_merge(array_slice($order, $offset), array_slice($order, 0, $offset)); - usort($order, static function ($a, $b) use ($targetSizes, $currentCounts) { - $remainingA = $targetSizes[$a] - $currentCounts[$a]; - $remainingB = $targetSizes[$b] - $currentCounts[$b]; - return $remainingB <=> $remainingA; - }); - - foreach ($order as $sectionIdx) { - if ($extra <= 0) break; - if ($currentCounts[$sectionIdx] >= $targetSizes[$sectionIdx]) continue; - $allocations[$groupName][$sectionIdx]++; - $currentCounts[$sectionIdx]++; - $extra--; - } - $groups[$groupName] = $groupStudents; - $groupIndex++; - } - - foreach ($groups as $groupName => $groupStudents) { - $quotas = $allocations[$groupName]; - foreach ($groupStudents as $idx => $student) { - $round = intdiv($idx, max(1, $sectionCount)); - $order = range(0, $sectionCount - 1); - if ($round % 2 === 1) { - $order = array_reverse($order); + for ($sectionIdx = 0; $sectionIdx < $sectionCount; $sectionIdx++) { + if ($currentCounts[$sectionIdx] >= $targetSizes[$sectionIdx]) { + continue; } - foreach ($order as $sectionIdx) { - if (($quotas[$sectionIdx] ?? 0) <= 0) continue; + + $genderRemaining = ($targetGenderCounts[$gender][$sectionIdx] ?? 0) - ($buckets[$sectionIdx]['gender_counts'][$gender] ?? 0); + $totalRemaining = $targetSizes[$sectionIdx] - $currentCounts[$sectionIdx]; + $score = [ + $genderRemaining > 0 ? 1 : 0, + $genderRemaining, + $totalRemaining, + -$currentCounts[$sectionIdx], + -$sectionIdx, + ]; + + if ($bestScore === null || $score > $bestScore) { + $best = $sectionIdx; + $bestScore = $score; + } + } + + return $best; + }; + + foreach ($groups as $genderGroups) { + foreach (['male', 'female', 'other'] as $gender) { + $groupStudents = $genderGroups[$gender] ?? []; + usort($groupStudents, static fn($a, $b): int => ((float)$b['previous_final_score']) <=> ((float)$a['previous_final_score'])); + + foreach ($groupStudents as $student) { + $sectionIdx = $chooseSection($gender); + if ($sectionIdx === null) { + continue; + } + $buckets[$sectionIdx]['assigned'][] = $student; - $quotas[$sectionIdx]--; - break; + $buckets[$sectionIdx]['gender_counts'][$gender]++; + $currentCounts[$sectionIdx]++; } } } @@ -1281,6 +2053,19 @@ class StudentController extends BaseController return $this->balanceDistributionAverages($buckets, $minPerSection, $maxPerSection); } + private function distributionGenderKey(array $student): string + { + $gender = strtolower(trim((string)($student['gender'] ?? ''))); + if ($gender === 'female' || $gender === 'f') { + return 'female'; + } + if ($gender === 'male' || $gender === 'm') { + return 'male'; + } + + return 'other'; + } + private function balanceDistributionAverages(array $buckets, int $minPerSection, ?int $maxPerSection): array { for ($i = 0; $i < 50; $i++) { @@ -1299,6 +2084,7 @@ class StudentController extends BaseController foreach ($buckets[$highIdx]['assigned'] as $hiPos => $hiStudent) { foreach ($buckets[$lowIdx]['assigned'] as $loPos => $loStudent) { if ($hiStudent['score_group'] !== $loStudent['score_group']) continue; + if ($this->distributionGenderKey($hiStudent) !== $this->distributionGenderKey($loStudent)) continue; $trial = $buckets; $trial[$highIdx]['assigned'][$hiPos] = $loStudent; $trial[$lowIdx]['assigned'][$loPos] = $hiStudent; @@ -1334,8 +2120,14 @@ class StudentController extends BaseController try { $year = trim((string)($this->request->getGet('school_year') ?? $this->schoolYear)); $includeClassIds = $this->parseIncludedClassIds($this->request->getGet('include_class_ids')); + $includeClassIds = array_values(array_unique(array_merge( + $includeClassIds, + $this->pendingDistributionDraftClassIds($year) + ))); + $draftTotalsByClassId = $this->pendingDistributionDraftTotalsByClassId($year); + $draftStudentIds = $this->pendingDistributionDraftStudentIds($year); - // Fetch base sections (no dash) and filter to KG, 1..9, youth + // Fetch base sections (no dash) and filter to KG, 1..10, Youth $baseQuery = $this->classSectionModel ->where("class_section_name NOT LIKE '%-%'", null, false) ->orderBy('class_id', 'ASC'); @@ -1355,7 +2147,7 @@ class StudentController extends BaseController $nameRaw = (string)($r['class_section_name'] ?? ''); $name = strtolower($nameRaw); - // Only KG, 1..9, Youth per request + // Only KG, 1..10, Youth per request if ($name === 'kg' || $name === 'youth') { $wanted[] = $r; continue; @@ -1363,7 +2155,7 @@ class StudentController extends BaseController if (ctype_digit($name)) { $num = (int)$name; - if ($num >= 1 && $num <= 9) { + if ($num >= 1 && $num <= 10) { $wanted[] = $r; continue; } @@ -1389,23 +2181,18 @@ class StudentController extends BaseController $out = []; foreach ($wanted as $r) { $classId = (int)$r['class_id']; - $cands = $this->db->table('promotion_queue pq') - ->select('pq.student_id') - ->where('pq.to_class_id', $classId) - ->where('pq.school_year_to', $year) - ->whereIn('pq.status', ['queued','assigned']) - ->groupBy('pq.student_id') - ->get()->getResultArray(); - $total = count($cands); - if ($total === 0) { - $total = count($this->decisionDistributionCandidates($classId, $year)); - } + $candidateStudents = array_values(array_filter( + $this->distributionCandidates($classId, $year), + static fn(array $student): bool => !isset($draftStudentIds[(int)($student['student_id'] ?? 0)]) + )); + $total = count($candidateStudents) + ($draftTotalsByClassId[$classId] ?? 0); $out[] = [ 'class_id' => $classId, 'class_section_id' => (int)($r['class_section_id'] ?? 0), 'class_section_name'=> (string)($r['class_section_name'] ?? ''), 'total' => $total, + 'students' => $this->distributionCandidateSummaries($candidateStudents, $classId, (string)($r['class_section_name'] ?? '')), 'sections' => $this->savedDistributionSections($classId, $year), ]; } @@ -1432,14 +2219,262 @@ class StudentController extends BaseController ))); } + private function distributionCandidateSummaries(array $students, int $classId, string $className): array + { + $out = []; + foreach ($students as $student) { + $studentId = (int)($student['student_id'] ?? 0); + $studentName = trim((string)($student['student_name'] ?? '')); + if ($studentName === '') { + $studentName = 'Student #' . $studentId; + } + + $out[] = [ + 'draft_id' => 0, + 'student_id' => $studentId, + 'student_name' => $studentName, + 'age_at_reference' => $student['age_at_reference'] ?? null, + 'gender' => (string)($student['gender'] ?? ''), + 'last_year_class_section' => (string)($student['last_year_class_section'] ?? ''), + 'class_id' => $classId, + 'class_section_id' => 0, + 'class_section_name' => $className, + ]; + } + + usort($out, static function (array $a, array $b): int { + return strnatcasecmp((string)($a['student_name'] ?? ''), (string)($b['student_name'] ?? '')); + }); + + return $out; + } + + private function applyDistributionAgeFilter($builder, string $schoolYear): void + { + [$earliestDob, $latestDob] = $this->distributionAgeBirthDateWindow($schoolYear); + + $builder + ->where('students.dob IS NOT NULL', null, false) + ->where('students.dob >=', $earliestDob) + ->where('students.dob <=', $latestDob); + } + + private function distributionAgeBirthDateWindow(string $schoolYear): array + { + $reference = $this->distributionAgeReferenceDate($schoolYear); + + return [ + $reference->modify('-18 years +1 day')->format('Y-m-d'), + $reference->modify('-5 years')->format('Y-m-d'), + ]; + } + + private function distributionAgeReferenceDate(string $schoolYear): \DateTimeImmutable + { + $schoolYear = trim($schoolYear); + if (!preg_match('/^(\d{4})/', $schoolYear, $matches)) { + throw new \RuntimeException('School year is required for auto-distribution age filtering.'); + } + + $timezone = new \DateTimeZone((string)(config('School')->attendance['timezone'] ?? user_timezone())); + $reference = new \DateTimeImmutable($matches[1] . '-09-01', $timezone); + + return $reference->setTime(0, 0, 0); + } + + private function distributionAgeAtReference($dob, string $schoolYear): ?int + { + $dob = trim((string)$dob); + if ($dob === '') { + return null; + } + + $timezone = new \DateTimeZone((string)(config('School')->attendance['timezone'] ?? user_timezone())); + $birthDate = \DateTimeImmutable::createFromFormat('!Y-m-d', $dob, $timezone); + $errors = \DateTimeImmutable::getLastErrors(); + $hasErrors = is_array($errors) && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0); + + if ($birthDate === false || $hasErrors) { + return null; + } + + $reference = $this->distributionAgeReferenceDate($schoolYear); + if ($birthDate > $reference) { + return null; + } + + return $birthDate->diff($reference)->y; + } + + private function distributionTargetClassIdForStudent(?int $defaultClassId, ?int $ageAtReference, string $schoolYear, string $sourceClassName = ''): ?int + { + if ($defaultClassId === null && $ageAtReference !== null && $ageAtReference < 6) { + return $this->distributionBaseClassIdByName('KG', $schoolYear); + } + + if ($this->isDistributionKgSource($defaultClassId, $sourceClassName, $schoolYear)) { + if ($ageAtReference !== null && $ageAtReference < 6) { + return $this->distributionBaseClassIdByName('KG', $schoolYear) ?? $defaultClassId; + } + + if ($ageAtReference !== null && $ageAtReference >= 6) { + return $this->distributionBaseClassIdByName('1', $schoolYear) ?? $defaultClassId; + } + } + + if ($defaultClassId !== null) { + return $defaultClassId; + } + + if ($ageAtReference === 14) { + return $this->distributionBaseClassIdByName('9', $schoolYear); + } + + if ($ageAtReference === 15) { + return $this->distributionBaseClassIdByName('10', $schoolYear); + } + + if ($ageAtReference === 16 || $ageAtReference === 17) { + return $this->distributionBaseClassIdByName('YOUTH', $schoolYear); + } + + return null; + } + + private function isDistributionKgSource(?int $classId, string $sourceClassName, string $schoolYear): bool + { + $sourceClassName = strtoupper(trim(preg_replace('/-.+$/', '', $sourceClassName) ?? '')); + if (in_array($sourceClassName, ['KG', 'K', 'KINDERGARTEN'], true)) { + return true; + } + + return $classId !== null && $classId > 0 && $this->isDistributionKgClass($classId, $schoolYear); + } + + private function distributionBaseClassIdByName(string $baseName, string $schoolYear): ?int + { + $normalized = strtoupper(trim($baseName)); + $cacheKey = $schoolYear . ':' . $normalized; + if (array_key_exists($cacheKey, $this->distributionBaseClassIdCache)) { + return $this->distributionBaseClassIdCache[$cacheKey]; + } + + $query = $this->classSectionModel + ->select('class_id') + ->where('UPPER(class_section_name)', $normalized) + ->where("class_section_name NOT LIKE '%-%'", null, false) + ->orderBy('id', 'DESC'); + if ($schoolYear !== '' && $this->db->fieldExists('school_year', 'classSection')) { + $query->where('school_year', $schoolYear); + } + + $row = $query->first(); + if (!$row && $schoolYear !== '' && $this->db->fieldExists('school_year', 'classSection')) { + $row = $this->classSectionModel + ->select('class_id') + ->where('UPPER(class_section_name)', $normalized) + ->where("class_section_name NOT LIKE '%-%'", null, false) + ->orderBy('id', 'DESC') + ->first(); + } + + $this->distributionBaseClassIdCache[$cacheKey] = $row ? (int)$row['class_id'] : null; + + return $this->distributionBaseClassIdCache[$cacheKey]; + } + + private function pendingDistributionDraftClassIds(string $year): array + { + if ($year === '' || ! $this->db->tableExists('student_section_distribution_drafts')) { + return []; + } + + $builder = $this->db->table('student_section_distribution_drafts') + ->join('students', 'students.id = student_section_distribution_drafts.student_id', 'left') + ->select('student_section_distribution_drafts.class_id') + ->where('student_section_distribution_drafts.school_year', $year) + ->where('student_section_distribution_drafts.status', 'pending') + ->groupBy('student_section_distribution_drafts.class_id'); + $excludedByDecision = $this->distributionExcludedDecisionStudentIds($year); + if (!empty($excludedByDecision)) { + $builder->whereNotIn('student_section_distribution_drafts.student_id', array_keys($excludedByDecision)); + } + $this->applyDistributionAgeFilter($builder, $year); + $rows = $builder + ->get() + ->getResultArray(); + + return array_values(array_unique(array_filter( + array_map(static fn(array $row): int => (int)($row['class_id'] ?? 0), $rows), + static fn(int $id): bool => $id > 0 + ))); + } + + private function pendingDistributionDraftTotalsByClassId(string $year): array + { + if ($year === '' || ! $this->db->tableExists('student_section_distribution_drafts')) { + return []; + } + + $builder = $this->db->table('student_section_distribution_drafts') + ->join('students', 'students.id = student_section_distribution_drafts.student_id', 'left') + ->select('student_section_distribution_drafts.class_id, COUNT(*) AS total', false) + ->where('student_section_distribution_drafts.school_year', $year) + ->where('student_section_distribution_drafts.status', 'pending') + ->groupBy('student_section_distribution_drafts.class_id'); + $excludedByDecision = $this->distributionExcludedDecisionStudentIds($year); + if (!empty($excludedByDecision)) { + $builder->whereNotIn('student_section_distribution_drafts.student_id', array_keys($excludedByDecision)); + } + $this->applyDistributionAgeFilter($builder, $year); + $rows = $builder + ->get() + ->getResultArray(); + + $totals = []; + foreach ($rows as $row) { + $classId = (int)($row['class_id'] ?? 0); + if ($classId > 0) { + $totals[$classId] = (int)($row['total'] ?? 0); + } + } + + return $totals; + } + + private function pendingDistributionDraftStudentIds(string $year): array + { + if ($year === '' || ! $this->db->tableExists('student_section_distribution_drafts')) { + return []; + } + + $rows = $this->db->table('student_section_distribution_drafts') + ->select('student_id') + ->where('school_year', $year) + ->where('status', 'pending') + ->get() + ->getResultArray(); + + $excludedByDecision = $this->distributionExcludedDecisionStudentIds($year); + $ids = []; + foreach ($rows as $row) { + $studentId = (int)($row['student_id'] ?? 0); + if ($studentId > 0 && !isset($excludedByDecision[$studentId])) { + $ids[$studentId] = true; + } + } + + return $ids; + } + private function savedDistributionSections(int $classId, string $year): array { if (! $this->db->tableExists('student_section_distribution_drafts')) { return []; } - $rows = $this->db->table('student_section_distribution_drafts d') - ->select('d.class_section_id, cs.class_section_name, students.firstname, students.lastname, d.student_id') + $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') ->join('students', 'students.id = d.student_id', 'left') ->where('d.class_id', $classId) @@ -1447,30 +2482,59 @@ class StudentController extends BaseController ->where('d.status', 'pending') ->orderBy('cs.class_section_name', 'ASC') ->orderBy('students.lastname', 'ASC') - ->orderBy('students.firstname', 'ASC') + ->orderBy('students.firstname', 'ASC'); + $this->applyDistributionAgeFilter($builder, $year); + $rows = $builder ->get() ->getResultArray(); + if (empty($rows)) { + return []; + } + $sections = []; + $excludedByDecision = $this->distributionExcludedDecisionStudentIds($year); + foreach ($rows as $row) { + $studentId = (int)($row['student_id'] ?? 0); + if ($studentId <= 0 || isset($excludedByDecision[$studentId])) { + continue; + } + $sectionId = (int)($row['class_section_id'] ?? 0); if ($sectionId <= 0) { continue; } if (!isset($sections[$sectionId])) { $sections[$sectionId] = [ + 'class_id' => (int)($row['class_id'] ?? $classId), 'class_section_id' => $sectionId, 'class_section_name' => (string)($row['class_section_name'] ?? $sectionId), 'total' => 0, 'student_names' => [], + 'student_assignments' => [], ]; } $name = trim(trim((string)($row['firstname'] ?? '')) . ' ' . trim((string)($row['lastname'] ?? ''))); if ($name === '') { - $name = 'Student #' . (int)($row['student_id'] ?? 0); + $name = 'Student #' . $studentId; } $sections[$sectionId]['student_names'][] = $name; + $sections[$sectionId]['student_assignments'][] = [ + 'draft_id' => (int)($row['draft_id'] ?? 0), + 'student_id' => $studentId, + 'student_name' => $name, + 'age_at_reference' => $this->distributionAgeAtReference($row['dob'] ?? null, $year), + 'gender' => (string)($row['gender'] ?? ''), + 'last_year_class_section' => $this->distributionPreviousClassSectionName( + $studentId, + $year, + (string)($row['previous_school_year'] ?? '') + ), + 'class_id' => (int)($row['class_id'] ?? $classId), + 'class_section_id' => $sectionId, + ]; $sections[$sectionId]['total']++; } @@ -1570,10 +2634,11 @@ class StudentController extends BaseController } $dobStr = $dob->format('Y-m-d'); - $today = new \DateTimeImmutable('today', $tzLocal); - $age = (int)$today->format('Y') - (int)$dob->format('Y'); - if ((int)$today->format('md') < (int)$dob->format('md')) $age--; - if ($age < 0) throw new \RuntimeException('DOB results in negative age.'); + $ageSchoolYear = $in['school_year'] ?: $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); + $age = $this->distributionAgeAtReference($dobStr, $ageSchoolYear); + if ($age === null) { + throw new \RuntimeException('DOB results in invalid age for the selected school year.'); + } // registration_date: accept date or datetime-local; store as UTC DATETIME $regDateStr = null; diff --git a/app/Database/Migrations/2026-08-06-000100_AddSchoolYearTransitionEnrollmentFields.php b/app/Database/Migrations/2026-08-06-000100_AddSchoolYearTransitionEnrollmentFields.php new file mode 100644 index 0000000..0d4e051 --- /dev/null +++ b/app/Database/Migrations/2026-08-06-000100_AddSchoolYearTransitionEnrollmentFields.php @@ -0,0 +1,188 @@ +db->tableExists('student_decisions') && ! $this->db->fieldExists('deliberation_decision_standard', 'student_decisions')) { + $this->forge->addColumn('student_decisions', [ + 'deliberation_decision_standard' => [ + 'type' => 'VARCHAR', + 'constraint' => 40, + 'null' => true, + 'after' => 'decision', + ], + ]); + } + + if ($this->db->tableExists('student_decisions') && $this->db->fieldExists('deliberation_decision_standard', 'student_decisions')) { + $rows = $this->db->table('student_decisions') + ->select('id, decision') + ->get() + ->getResultArray(); + + foreach ($rows as $row) { + $standard = DeliberationDecision::normalize($row['decision'] ?? null); + if ($standard === null) { + continue; + } + + $this->db->table('student_decisions') + ->where('id', (int) $row['id']) + ->update(['deliberation_decision_standard' => $standard]); + } + } + + if (! $this->db->tableExists('enrollments')) { + return; + } + + $fields = []; + $this->addFieldIfMissing($fields, 'source_school_year', [ + 'type' => 'VARCHAR', + 'constraint' => 20, + 'null' => true, + 'after' => 'school_year', + ]); + $this->addFieldIfMissing($fields, 'deliberation_decision', [ + 'type' => 'VARCHAR', + 'constraint' => 40, + 'null' => true, + 'after' => 'source_school_year', + ]); + $this->addFieldIfMissing($fields, 'source_grade_id', [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => true, + 'after' => 'deliberation_decision', + ]); + $this->addFieldIfMissing($fields, 'assigned_grade_id', [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => true, + 'after' => 'source_grade_id', + ]); + $this->addFieldIfMissing($fields, 'source_class_section_id', [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => true, + 'after' => 'assigned_grade_id', + ]); + $this->addFieldIfMissing($fields, 'assigned_class_section_id', [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => true, + 'after' => 'source_class_section_id', + ]); + $this->addFieldIfMissing($fields, 'placement_status', [ + 'type' => 'VARCHAR', + 'constraint' => 40, + 'null' => true, + 'after' => 'assigned_class_section_id', + ]); + $this->addFieldIfMissing($fields, 'age_reference_date', [ + 'type' => 'DATE', + 'null' => true, + 'after' => 'placement_status', + ]); + $this->addFieldIfMissing($fields, 'age_on_reference_date', [ + 'type' => 'INT', + 'constraint' => 3, + 'null' => true, + 'after' => 'age_reference_date', + ]); + $this->addFieldIfMissing($fields, 'adult_student', [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 0, + 'after' => 'age_on_reference_date', + ]); + $this->addFieldIfMissing($fields, 'parent_enrollment_allowed', [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 1, + 'after' => 'adult_student', + ]); + $this->addFieldIfMissing($fields, 'student_self_enrollment_allowed', [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 0, + 'after' => 'parent_enrollment_allowed', + ]); + $this->addFieldIfMissing($fields, 'exception_required', [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 0, + 'after' => 'student_self_enrollment_allowed', + ]); + $this->addFieldIfMissing($fields, 'exception_reason', [ + 'type' => 'TEXT', + 'null' => true, + 'after' => 'exception_required', + ]); + $this->addFieldIfMissing($fields, 'registration_submitted_at', [ + 'type' => 'DATETIME', + 'null' => true, + 'after' => 'exception_reason', + ]); + $this->addFieldIfMissing($fields, 'registration_confirmed_at', [ + 'type' => 'DATETIME', + 'null' => true, + 'after' => 'registration_submitted_at', + ]); + + if ($fields !== []) { + $this->forge->addColumn('enrollments', $fields); + } + } + + public function down() + { + if ($this->db->tableExists('student_decisions') && $this->db->fieldExists('deliberation_decision_standard', 'student_decisions')) { + $this->forge->dropColumn('student_decisions', 'deliberation_decision_standard'); + } + + if (! $this->db->tableExists('enrollments')) { + return; + } + + foreach ([ + 'source_school_year', + 'deliberation_decision', + 'source_grade_id', + 'assigned_grade_id', + 'source_class_section_id', + 'assigned_class_section_id', + 'placement_status', + 'age_reference_date', + 'age_on_reference_date', + 'adult_student', + 'parent_enrollment_allowed', + 'student_self_enrollment_allowed', + 'exception_required', + 'exception_reason', + 'registration_submitted_at', + 'registration_confirmed_at', + ] as $field) { + if ($this->db->fieldExists($field, 'enrollments')) { + $this->forge->dropColumn('enrollments', $field); + } + } + } + + private function addFieldIfMissing(array &$fields, string $name, array $definition): void + { + if (! $this->db->fieldExists($name, 'enrollments')) { + $fields[$name] = $definition; + } + } +} diff --git a/app/Database/Migrations/2026-08-06-000200_CreateEnrollmentPhaseTwoTables.php b/app/Database/Migrations/2026-08-06-000200_CreateEnrollmentPhaseTwoTables.php new file mode 100644 index 0000000..fe54066 --- /dev/null +++ b/app/Database/Migrations/2026-08-06-000200_CreateEnrollmentPhaseTwoTables.php @@ -0,0 +1,114 @@ +db->tableExists('school_years')) { + $schoolYearFields = []; + $this->addColumnIfMissing($schoolYearFields, 'school_years', 'registration_opens_at', ['type' => 'DATETIME', 'null' => true, 'after' => 'registration_starts_on']); + $this->addColumnIfMissing($schoolYearFields, 'school_years', 'registration_deadline_at', ['type' => 'DATETIME', 'null' => true, 'after' => 'registration_ends_on']); + $this->addColumnIfMissing($schoolYearFields, 'school_years', 'late_registration_blocked', ['type' => 'TINYINT', 'constraint' => 1, 'default' => 1, 'after' => 'registration_deadline_at']); + $this->addColumnIfMissing($schoolYearFields, 'school_years', 'administrative_exceptions_permitted', ['type' => 'TINYINT', 'constraint' => 1, 'default' => 1, 'after' => 'late_registration_blocked']); + $this->addColumnIfMissing($schoolYearFields, 'school_years', 'registration_exception_roles', ['type' => 'TEXT', 'null' => true, 'after' => 'administrative_exceptions_permitted']); + $this->addColumnIfMissing($schoolYearFields, 'school_years', 'adult_student_registration_enabled', ['type' => 'TINYINT', 'constraint' => 1, 'default' => 0, 'after' => 'registration_exception_roles']); + if ($schoolYearFields !== []) { + $this->forge->addColumn('school_years', $schoolYearFields); + } + } + + if (! $this->db->tableExists('enrollment_age_rules')) { + $this->forge->addField([ + 'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true], + 'school_year' => ['type' => 'VARCHAR', 'constraint' => 20], + 'campus' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true], + 'grade_class_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'education_level' => ['type' => 'VARCHAR', 'constraint' => 80, 'null' => true], + 'minimum_age' => ['type' => 'INT', 'constraint' => 3, 'null' => true], + 'maximum_age' => ['type' => 'INT', 'constraint' => 3, 'null' => true], + 'age_reference_date' => ['type' => 'DATE', 'null' => true], + 'behavior' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => 'blocking'], + 'exceptions_allowed' => ['type' => 'TINYINT', 'constraint' => 1, 'default' => 0], + 'exception_roles' => ['type' => 'TEXT', 'null' => true], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey(['school_year', 'grade_class_id']); + $this->forge->createTable('enrollment_age_rules'); + } + + if (! $this->db->tableExists('enrollment_flags')) { + $this->forge->addField([ + 'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true], + 'flag_type' => ['type' => 'VARCHAR', 'constraint' => 80], + 'student_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true], + 'school_year' => ['type' => 'VARCHAR', 'constraint' => 20], + 'source_school_year' => ['type' => 'VARCHAR', 'constraint' => 20, 'null' => true], + 'status' => ['type' => 'VARCHAR', 'constraint' => 40, 'default' => 'open'], + 'priority' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => 'normal'], + 'assigned_to' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'details_json' => ['type' => 'TEXT', 'null' => true], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'resolved_at' => ['type' => 'DATETIME', 'null' => true], + 'resolution_notes' => ['type' => 'TEXT', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey(['student_id', 'school_year', 'flag_type']); + $this->forge->createTable('enrollment_flags'); + } + + if (! $this->db->tableExists('enrollment_transition_audits')) { + $this->forge->addField([ + 'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true], + 'student_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true], + 'school_year' => ['type' => 'VARCHAR', 'constraint' => 20], + 'source_school_year' => ['type' => 'VARCHAR', 'constraint' => 20, 'null' => true], + 'action' => ['type' => 'VARCHAR', 'constraint' => 80], + 'performed_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'original_values_json' => ['type' => 'TEXT', 'null' => true], + 'new_values_json' => ['type' => 'TEXT', 'null' => true], + 'reason' => ['type' => 'TEXT', 'null' => true], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey(['student_id', 'school_year', 'created_at']); + $this->forge->createTable('enrollment_transition_audits'); + } + } + + public function down() + { + $this->forge->dropTable('enrollment_transition_audits', true); + $this->forge->dropTable('enrollment_flags', true); + $this->forge->dropTable('enrollment_age_rules', true); + + if (! $this->db->tableExists('school_years')) { + return; + } + + foreach ([ + 'registration_opens_at', + 'registration_deadline_at', + 'late_registration_blocked', + 'administrative_exceptions_permitted', + 'registration_exception_roles', + 'adult_student_registration_enabled', + ] as $field) { + if ($this->db->fieldExists($field, 'school_years')) { + $this->forge->dropColumn('school_years', $field); + } + } + } + + private function addColumnIfMissing(array &$fields, string $table, string $name, array $definition): void + { + if (! $this->db->fieldExists($name, $table)) { + $fields[$name] = $definition; + } + } +} diff --git a/app/Database/Migrations/2026-08-06-000300_AddRegistrationExperienceFinancialFields.php b/app/Database/Migrations/2026-08-06-000300_AddRegistrationExperienceFinancialFields.php new file mode 100644 index 0000000..f93de30 --- /dev/null +++ b/app/Database/Migrations/2026-08-06-000300_AddRegistrationExperienceFinancialFields.php @@ -0,0 +1,54 @@ +db->tableExists('school_years')) { + return; + } + + $fields = []; + $this->addColumnIfMissing($fields, 'registration_fee', ['type' => 'DECIMAL', 'constraint' => '10,2', 'default' => 0, 'after' => 'adult_student_registration_enabled']); + $this->addColumnIfMissing($fields, 'tuition_due_at_registration', ['type' => 'DECIMAL', 'constraint' => '10,2', 'default' => 0, 'after' => 'registration_fee']); + $this->addColumnIfMissing($fields, 'mandatory_fees', ['type' => 'DECIMAL', 'constraint' => '10,2', 'default' => 0, 'after' => 'tuition_due_at_registration']); + $this->addColumnIfMissing($fields, 'carry_over_balance_behavior', ['type' => 'VARCHAR', 'constraint' => 60, 'default' => 'information_only', 'after' => 'mandatory_fees']); + $this->addColumnIfMissing($fields, 'financial_policy_message', ['type' => 'TEXT', 'null' => true, 'after' => 'carry_over_balance_behavior']); + $this->addColumnIfMissing($fields, 'payment_plan_available', ['type' => 'TINYINT', 'constraint' => 1, 'default' => 0, 'after' => 'financial_policy_message']); + + if ($fields !== []) { + $this->forge->addColumn('school_years', $fields); + } + } + + public function down() + { + if (! $this->db->tableExists('school_years')) { + return; + } + + foreach ([ + 'registration_fee', + 'tuition_due_at_registration', + 'mandatory_fees', + 'carry_over_balance_behavior', + 'financial_policy_message', + 'payment_plan_available', + ] as $field) { + if ($this->db->fieldExists($field, 'school_years')) { + $this->forge->dropColumn('school_years', $field); + } + } + } + + private function addColumnIfMissing(array &$fields, string $name, array $definition): void + { + if (! $this->db->fieldExists($name, 'school_years')) { + $fields[$name] = $definition; + } + } +} diff --git a/app/Database/Migrations/2026-08-06-000400_CreateEnrollmentEmailRecords.php b/app/Database/Migrations/2026-08-06-000400_CreateEnrollmentEmailRecords.php new file mode 100644 index 0000000..1a40eb8 --- /dev/null +++ b/app/Database/Migrations/2026-08-06-000400_CreateEnrollmentEmailRecords.php @@ -0,0 +1,70 @@ +db->tableExists('school_years')) { + $fields = []; + $this->addSchoolYearColumnIfMissing($fields, 'registration_launch_approved_at', ['type' => 'DATETIME', 'null' => true, 'after' => 'payment_plan_available']); + $this->addSchoolYearColumnIfMissing($fields, 'registration_launch_approved_by', ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true, 'after' => 'registration_launch_approved_at']); + $this->addSchoolYearColumnIfMissing($fields, 'registration_email_template_version', ['type' => 'VARCHAR', 'constraint' => 80, 'null' => true, 'after' => 'registration_launch_approved_by']); + if ($fields !== []) { + $this->forge->addColumn('school_years', $fields); + } + } + + if ($this->db->tableExists('enrollment_email_records')) { + return; + } + + $this->forge->addField([ + 'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true], + 'school_year' => ['type' => 'VARCHAR', 'constraint' => 20], + 'school_year_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'family_account_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'parent_user_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'recipient_addresses_json' => ['type' => 'TEXT'], + 'template_version' => ['type' => 'VARCHAR', 'constraint' => 80, 'null' => true], + 'generated_subject' => ['type' => 'VARCHAR', 'constraint' => 255], + 'generated_body' => ['type' => 'LONGTEXT'], + 'student_ids_included_json' => ['type' => 'TEXT', 'null' => true], + 'generated_at' => ['type' => 'DATETIME', 'null' => true], + 'sent_at' => ['type' => 'DATETIME', 'null' => true], + 'delivery_status' => ['type' => 'VARCHAR', 'constraint' => 40, 'default' => 'generated'], + 'failure_reason' => ['type' => 'TEXT', 'null' => true], + 'retry_count' => ['type' => 'INT', 'constraint' => 11, 'default' => 0], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey(['school_year', 'parent_user_id']); + $this->forge->createTable('enrollment_email_records'); + } + + public function down() + { + $this->forge->dropTable('enrollment_email_records', true); + + if (! $this->db->tableExists('school_years')) { + return; + } + + foreach (['registration_launch_approved_at', 'registration_launch_approved_by', 'registration_email_template_version'] as $field) { + if ($this->db->fieldExists($field, 'school_years')) { + $this->forge->dropColumn('school_years', $field); + } + } + } + + private function addSchoolYearColumnIfMissing(array &$fields, string $name, array $definition): void + { + if (! $this->db->fieldExists($name, 'school_years')) { + $fields[$name] = $definition; + } + } +} diff --git a/app/Database/Migrations/2026-08-06-000500_AddEnrollmentAdministrationNavItem.php b/app/Database/Migrations/2026-08-06-000500_AddEnrollmentAdministrationNavItem.php new file mode 100644 index 0000000..dcf2ff3 --- /dev/null +++ b/app/Database/Migrations/2026-08-06-000500_AddEnrollmentAdministrationNavItem.php @@ -0,0 +1,190 @@ +db->tableExists('nav_items')) { + return; + } + + $parentColumn = $this->parentColumn(); + $parent = $this->studentAffairsParent($parentColumn); + $navItemId = $this->navItemId($parentColumn, $parent); + + if ($navItemId <= 0) { + return; + } + + $this->grantRoles($navItemId, [ + 'administrator', + 'principal', + 'vice_principal', + 'head of department (education)', + ]); + + cache()->clean(); + } + + public function down(): void + { + if (! $this->db->tableExists('nav_items')) { + return; + } + + $row = $this->db->table('nav_items') + ->where('url', $this->url) + ->get() + ->getRowArray(); + + if ($row === null) { + return; + } + + if ($this->db->tableExists('role_nav_items')) { + $this->db->table('role_nav_items') + ->where('nav_item_id', (int) $row['id']) + ->delete(); + } + + $this->db->table('nav_items') + ->where('id', (int) $row['id']) + ->delete(); + + cache()->clean(); + } + + private function navItemId(?string $parentColumn, ?array $parent): int + { + $existing = $this->db->table('nav_items') + ->where('url', $this->url) + ->get() + ->getRowArray(); + + if ($existing !== null) { + $updates = [ + 'label' => $this->label, + 'is_enabled' => 1, + 'updated_at' => date('Y-m-d H:i:s'), + ]; + if ($parentColumn !== null && ! empty($parent['id'])) { + $updates[$parentColumn] = (int) $parent['id']; + } + + $this->db->table('nav_items') + ->where('id', (int) $existing['id']) + ->update($updates); + + return (int) $existing['id']; + } + + $insert = [ + 'label' => $this->label, + 'url' => $this->url, + 'sort_order' => 6, + 'is_enabled' => 1, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]; + if ($this->db->fieldExists('icon_class', 'nav_items')) { + $insert['icon_class'] = 'bi bi-diagram-3'; + } + if ($parentColumn !== null && ! empty($parent['id'])) { + $insert[$parentColumn] = (int) $parent['id']; + } + + $this->db->table('nav_items')->insert($insert); + + return (int) $this->db->insertID(); + } + + private function studentAffairsParent(?string $parentColumn): ?array + { + $builder = $this->db->table('nav_items')->where('label', 'Student-Affairs'); + if ($parentColumn !== null) { + $builder->where($parentColumn, null); + } + + return $builder->get()->getRowArray(); + } + + private function grantRoles(int $navItemId, array $roleNames): void + { + if (! $this->db->tableExists('role_nav_items')) { + return; + } + + if ($this->db->fieldExists('role_id', 'role_nav_items') && $this->db->tableExists('roles')) { + foreach ($roleNames as $roleName) { + $roleBuilder = $this->db->table('roles')->select('id'); + $roleBuilder->groupStart() + ->where('LOWER(name)', strtolower($roleName)); + if ($this->db->fieldExists('slug', 'roles')) { + $roleBuilder->orWhere('LOWER(slug)', strtolower($roleName)); + } + $role = $roleBuilder->groupEnd() + ->limit(1) + ->get() + ->getRowArray(); + if ($role === null) { + continue; + } + + $exists = $this->db->table('role_nav_items') + ->where('role_id', (int) $role['id']) + ->where('nav_item_id', $navItemId) + ->countAllResults() > 0; + if (! $exists) { + $this->db->table('role_nav_items')->insert([ + 'role_id' => (int) $role['id'], + 'nav_item_id' => $navItemId, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + } + } + + return; + } + + if (! $this->db->fieldExists('role', 'role_nav_items')) { + return; + } + + foreach ($roleNames as $roleName) { + $roleName = strtolower($roleName); + $exists = $this->db->table('role_nav_items') + ->where('role', $roleName) + ->where('nav_item_id', $navItemId) + ->countAllResults() > 0; + if (! $exists) { + $this->db->table('role_nav_items')->insert([ + 'role' => $roleName, + 'nav_item_id' => $navItemId, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + } + } + } + + private function parentColumn(): ?string + { + if ($this->db->fieldExists('menu_parent_id', 'nav_items')) { + return 'menu_parent_id'; + } + + if ($this->db->fieldExists('parent_id', 'nav_items')) { + return 'parent_id'; + } + + return null; + } +} diff --git a/app/Database/Migrations/2026-08-06-000501_SyncEnrollmentAdministrationNavRoles.php b/app/Database/Migrations/2026-08-06-000501_SyncEnrollmentAdministrationNavRoles.php new file mode 100644 index 0000000..d1d2f43 --- /dev/null +++ b/app/Database/Migrations/2026-08-06-000501_SyncEnrollmentAdministrationNavRoles.php @@ -0,0 +1,77 @@ +db->tableExists('nav_items') || ! $this->db->tableExists('role_nav_items')) { + return; + } + + $nav = $this->db->table('nav_items') + ->select('id') + ->where('url', 'administrator/enrollment-admin') + ->limit(1) + ->get() + ->getRowArray(); + + if ($nav === null) { + return; + } + + $this->grantRoles((int) $nav['id'], [ + 'administrator', + 'principal', + 'vice_principal', + 'head of department (education)', + ]); + + cache()->clean(); + } + + public function down(): void + { + } + + private function grantRoles(int $navItemId, array $roleNames): void + { + if (! $this->db->fieldExists('role_id', 'role_nav_items') || ! $this->db->tableExists('roles')) { + return; + } + + foreach ($roleNames as $roleName) { + $roleBuilder = $this->db->table('roles')->select('id'); + $roleBuilder->groupStart() + ->where('LOWER(name)', strtolower($roleName)); + if ($this->db->fieldExists('slug', 'roles')) { + $roleBuilder->orWhere('LOWER(slug)', strtolower($roleName)); + } + $role = $roleBuilder->groupEnd() + ->limit(1) + ->get() + ->getRowArray(); + + if ($role === null) { + continue; + } + + $exists = $this->db->table('role_nav_items') + ->where('role_id', (int) $role['id']) + ->where('nav_item_id', $navItemId) + ->countAllResults() > 0; + + if (! $exists) { + $this->db->table('role_nav_items')->insert([ + 'role_id' => (int) $role['id'], + 'nav_item_id' => $navItemId, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + } + } + } +} diff --git a/app/Database/Migrations/2026-08-07-000100_AddReviewDecisionEnrollmentStatus.php b/app/Database/Migrations/2026-08-07-000100_AddReviewDecisionEnrollmentStatus.php new file mode 100644 index 0000000..4cb08fe --- /dev/null +++ b/app/Database/Migrations/2026-08-07-000100_AddReviewDecisionEnrollmentStatus.php @@ -0,0 +1,63 @@ +modifyEnrollmentStatusEnum(self::STATUSES_WITH_REVIEW_DECISION); + } + + public function down() + { + if ($this->db->tableExists('enrollments')) { + $this->db->table('enrollments') + ->where('enrollment_status', 'review & decision') + ->update(['enrollment_status' => 'admission under review']); + } + + $this->modifyEnrollmentStatusEnum(self::STATUSES_WITHOUT_REVIEW_DECISION); + } + + private function modifyEnrollmentStatusEnum(array $statuses): void + { + if (! $this->db->tableExists('enrollments') || ! $this->db->fieldExists('enrollment_status', 'enrollments')) { + return; + } + + if (! in_array($this->db->DBDriver, ['MySQLi', 'MySQL'], true)) { + return; + } + + $enumValues = implode(',', array_map(static fn (string $status): string => "'" . str_replace("'", "''", $status) . "'", $statuses)); + $this->db->query( + "ALTER TABLE `enrollments` MODIFY `enrollment_status` ENUM({$enumValues}) NOT NULL DEFAULT 'admission under review'" + ); + } +} diff --git a/app/Database/Seeds/NavSeeder.php b/app/Database/Seeds/NavSeeder.php index f5f438d..d9fd30b 100644 --- a/app/Database/Seeds/NavSeeder.php +++ b/app/Database/Seeds/NavSeeder.php @@ -67,13 +67,14 @@ class NavSeeder extends Seeder ['parent'=>'Student-Affairs','label'=>'Attendance Scans','url'=>'rfid_coming_soon','sort_order'=>3], ['parent'=>'Student-Affairs','label'=>'Classes List','url'=>'administrator/class_assignment','sort_order'=>4], ['parent'=>'Student-Affairs','label'=>'Emergency Contact','url'=>'administrator/emergency_contact','sort_order'=>5], - ['parent'=>'Student-Affairs','label'=>'Enrollment-Withdrawal','url'=>'enroll_withdraw/enrollment_withdrawal','sort_order'=>6], - ['parent'=>'Student-Affairs','label'=>'Flags Management','url'=>'flags/flags_management','sort_order'=>7], - ['parent'=>'Student-Affairs','label'=>'School Calendar','url'=>'administrator/calendar_view','sort_order'=>8], - ['parent'=>'Student-Affairs','label'=>'Score Analysis','url'=>'report/combined','sort_order'=>9], - ['parent'=>'Student-Affairs','label'=>'Score Management','url'=>'grading','sort_order'=>10], - ['parent'=>'Student-Affairs','label'=>'Student Class Assignment','url'=>'administrator/student_class_assignment','sort_order'=>11], - ['parent'=>'Student-Affairs','label'=>'Student Profile','url'=>'administrator/student_profiles','sort_order'=>12], + ['parent'=>'Student-Affairs','label'=>'Enrollment Administration','url'=>'administrator/enrollment-admin','sort_order'=>6], + ['parent'=>'Student-Affairs','label'=>'Enrollment-Withdrawal','url'=>'enroll_withdraw/enrollment_withdrawal','sort_order'=>7], + ['parent'=>'Student-Affairs','label'=>'Flags Management','url'=>'flags/flags_management','sort_order'=>8], + ['parent'=>'Student-Affairs','label'=>'School Calendar','url'=>'administrator/calendar_view','sort_order'=>9], + ['parent'=>'Student-Affairs','label'=>'Score Analysis','url'=>'report/combined','sort_order'=>10], + ['parent'=>'Student-Affairs','label'=>'Score Management','url'=>'grading','sort_order'=>11], + ['parent'=>'Student-Affairs','label'=>'Student Class Assignment','url'=>'administrator/student_class_assignment','sort_order'=>12], + ['parent'=>'Student-Affairs','label'=>'Student Profile','url'=>'administrator/student_profiles','sort_order'=>13], // Classes ['parent'=>'Classes','label'=>'Classes List','url'=>'administrator/class_assignment','sort_order'=>1], @@ -155,7 +156,7 @@ class NavSeeder extends Seeder } // Example: HOD Education (Student-Affairs group) - $hodEduLabels = ['Student-Affairs','Attendance Management','Classes List','Emergency Contact','Enrollment-Withdrawal','Flags Management','School Calendar','Score Analysis','Score Management','Student Class Assignment','Student Profile']; + $hodEduLabels = ['Student-Affairs','Attendance Management','Classes List','Emergency Contact','Enrollment Administration','Enrollment-Withdrawal','Flags Management','School Calendar','Score Analysis','Score Management','Student Class Assignment','Student Profile']; foreach ($navRows as $row) { if (in_array($row['label'], $hodEduLabels, true)) { $roleMap->insert([ diff --git a/app/Models/EnrollmentAgeRuleModel.php b/app/Models/EnrollmentAgeRuleModel.php new file mode 100644 index 0000000..3c1c52f --- /dev/null +++ b/app/Models/EnrollmentAgeRuleModel.php @@ -0,0 +1,28 @@ + 'permit_empty|integer', 'parent_id' => 'required|integer', 'school_year' => 'required|string|max_length[9]', + 'source_school_year' => 'permit_empty|string|max_length[20]', + 'deliberation_decision' => 'permit_empty|in_list[PASSED,REPEAT_CLASS,MAKE_UP_EXAM,EXPELLED,WITHDRAWN,DEFERRED_DECISION]', + 'source_grade_id' => 'permit_empty|integer', + 'assigned_grade_id' => 'permit_empty|integer', + 'source_class_section_id' => 'permit_empty|integer', + 'assigned_class_section_id' => 'permit_empty|integer', + 'placement_status' => 'permit_empty|string|max_length[40]', + 'age_reference_date' => 'permit_empty|valid_date', + 'age_on_reference_date' => 'permit_empty|integer', + 'adult_student' => 'permit_empty|in_list[0,1]', + 'parent_enrollment_allowed' => 'permit_empty|in_list[0,1]', + 'student_self_enrollment_allowed' => 'permit_empty|in_list[0,1]', + 'exception_required' => 'permit_empty|in_list[0,1]', 'enrollment_date' => 'required|valid_date', 'withdrawal_date' => 'permit_empty|valid_date', 'is_withdrawn' => 'permit_empty|in_list[0,1]', - 'enrollment_status' => 'required|in_list[admission under review,payment pending,enrolled,withdraw under review,refund pending,withdrawn]', + 'enrollment_status' => 'required|in_list[admission under review,review & decision,payment pending,enrolled,withdraw under review,refund pending,withdrawn,waitlist,denied]', 'admission_status' => 'required|in_list[pending,accepted,denied]', 'semester' => 'permit_empty|string|max_length[25]', ]; @@ -73,7 +102,7 @@ class EnrollmentModel extends Model ], 'enrollment_status' => [ 'required' => 'Enrollment status is required', - 'in_list' => 'Enrollment status must be one of: admission under review, payment pending, enrolled, withdraw under review, refund pending, withdrawn', + 'in_list' => 'Enrollment status must be one of: admission under review, review & decision, payment pending, enrolled, withdraw under review, refund pending, withdrawn, waitlist, denied', ], 'admission_status' => [ 'required' => 'Admission status is required', diff --git a/app/Models/EnrollmentTransitionAuditModel.php b/app/Models/EnrollmentTransitionAuditModel.php new file mode 100644 index 0000000..fd6a968 --- /dev/null +++ b/app/Models/EnrollmentTransitionAuditModel.php @@ -0,0 +1,25 @@ + 'permit_empty|valid_date[Y-m-d]', 'registration_starts_on' => 'permit_empty|valid_date[Y-m-d]', 'registration_ends_on' => 'permit_empty|valid_date[Y-m-d]', + 'registration_opens_at' => 'permit_empty|valid_date[Y-m-d H:i:s]', + 'registration_deadline_at' => 'permit_empty|valid_date[Y-m-d H:i:s]', + 'late_registration_blocked' => 'permit_empty|in_list[0,1]', + 'administrative_exceptions_permitted' => 'permit_empty|in_list[0,1]', + 'adult_student_registration_enabled' => 'permit_empty|in_list[0,1]', + 'registration_fee' => 'permit_empty|decimal', + 'tuition_due_at_registration' => 'permit_empty|decimal', + 'mandatory_fees' => 'permit_empty|decimal', + 'carry_over_balance_behavior' => 'permit_empty|in_list[information_only,payment_plan_required,submission_allowed_confirmation_blocked,submission_blocked_until_payment,admin_approval_required]', + 'payment_plan_available' => 'permit_empty|in_list[0,1]', + 'registration_launch_approved_at' => 'permit_empty|valid_date[Y-m-d H:i:s]', + 'registration_launch_approved_by' => 'permit_empty|integer', 'fall_makeup_exam_on' => 'permit_empty|valid_date[Y-m-d]', ]; diff --git a/app/Models/StudentDecisionModel.php b/app/Models/StudentDecisionModel.php index 461b3fa..64b2e6f 100644 --- a/app/Models/StudentDecisionModel.php +++ b/app/Models/StudentDecisionModel.php @@ -4,6 +4,7 @@ namespace App\Models; use CodeIgniter\Model; use App\Models\Concerns\SchoolYearAutoFillTrait; +use App\Support\Enrollment\DeliberationDecision; class StudentDecisionModel extends Model { @@ -18,6 +19,7 @@ class StudentDecisionModel extends Model 'class_section_name', 'year_score', 'decision', + 'deliberation_decision_standard', 'source', 'notes', 'generated_by', @@ -30,4 +32,18 @@ class StudentDecisionModel extends Model protected $useTimestamps = true; protected $createdField = 'created_at'; protected $updatedField = 'updated_at'; + protected $beforeInsert = ['standardizeDeliberationDecision']; + protected $beforeUpdate = ['standardizeDeliberationDecision']; + + protected function standardizeDeliberationDecision(array $data): array + { + if ( + array_key_exists('decision', $data['data'] ?? []) + && $this->db->fieldExists('deliberation_decision_standard', $this->table) + ) { + $data['data']['deliberation_decision_standard'] = DeliberationDecision::normalize($data['data']['decision'] ?? null); + } + + return $data; + } } diff --git a/app/Services/EnrollmentRegistrationEmailService.php b/app/Services/EnrollmentRegistrationEmailService.php new file mode 100644 index 0000000..0d73fde --- /dev/null +++ b/app/Services/EnrollmentRegistrationEmailService.php @@ -0,0 +1,486 @@ + 0, + 'recipients' => 0, + 'sent' => 0, + 'failed' => 0, + 'skipped' => 0, + 'dry_run' => $dryRun, + 'messages' => [], + ]; + + foreach ($this->registrationYearsForDate($date, $force) as $schoolYear) { + $summary['school_years']++; + if (! $dryRun && empty($schoolYear['registration_launch_approved_at'])) { + $summary['messages'][] = 'Registration launch is not approved for ' . (string) ($schoolYear['name'] ?? '') . '.'; + $summary['skipped']++; + continue; + } + + $result = $this->sendForSchoolYear($schoolYear, $testEmail, $dryRun, $force); + foreach (['recipients', 'sent', 'failed', 'skipped'] as $key) { + $summary[$key] += $result[$key] ?? 0; + } + array_push($summary['messages'], ...($result['messages'] ?? [])); + } + + return $summary; + } + + public function sendForSchoolYear(array $schoolYear, ?string $testEmail = null, bool $dryRun = false, bool $force = false): array + { + $summary = ['recipients' => 0, 'sent' => 0, 'failed' => 0, 'skipped' => 0, 'messages' => []]; + if (! $dryRun && empty($schoolYear['registration_launch_approved_at'])) { + $summary['messages'][] = 'Registration launch is not approved for ' . (string) ($schoolYear['name'] ?? '') . '.'; + $summary['skipped']++; + return $summary; + } + + $families = $this->recipientFamilies($schoolYear, $testEmail); + if ($families === []) { + $summary['messages'][] = 'No recipient families found for ' . (string) ($schoolYear['name'] ?? '') . '.'; + return $summary; + } + + foreach ($families as $family) { + if ($testEmail === null && ! $force && $this->alreadySent((string) $schoolYear['name'], (int) $family['parent_user_id'])) { + $summary['skipped']++; + continue; + } + + $message = $this->buildMessage($schoolYear, $family); + if ($testEmail !== null) { + $message['subject'] = '[TEST] ' . $message['subject']; + $message['recipients'] = [$testEmail]; + } + + $summary['recipients']++; + $recordId = $dryRun || ! $this->db->tableExists('enrollment_email_records') ? null : $this->recordGenerated($schoolYear, $family, $message); + $sent = $dryRun || $this->emailService->send($message['recipients'][0], $message['subject'], $message['body'], 'general'); + $sent ? $summary['sent']++ : $summary['failed']++; + + if (! $dryRun && $recordId !== null) { + $this->recordDelivery($recordId, $sent, $sent ? null : 'Email send failed'); + } + } + + return $summary; + } + + public function previewForParent(string $schoolYearName, int $parentId): ?array + { + $schoolYear = $this->schoolYearByName($schoolYearName); + if ($schoolYear === null) { + return null; + } + + foreach ($this->recipientFamilies($schoolYear, null) as $family) { + if ((int) $family['parent_user_id'] === $parentId) { + return $this->buildMessage($schoolYear, $family); + } + } + + return null; + } + + public function previewExamplesForSchoolYear(string $schoolYearName): array + { + $schoolYear = $this->schoolYearByName($schoolYearName); + if ($schoolYear === null) { + return []; + } + + $examples = []; + foreach ($this->recipientFamilies($schoolYear, null) as $family) { + $message = $this->buildMessage($schoolYear, $family); + $latest = $this->latestEmailRecord($schoolYearName, (int) $family['parent_user_id']); + + $examples[] = [ + 'parent_user_id' => (int) $family['parent_user_id'], + 'parent_name' => (string) $family['name'], + 'recipients' => $message['recipients'], + 'student_names' => array_map( + static fn (array $student): string => trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')) ?: 'Student #' . (int) ($student['id'] ?? 0), + $family['students'] + ), + 'subject' => (string) $message['subject'], + 'delivery_status' => (string) ($latest['delivery_status'] ?? 'not sent'), + 'sent_at' => $latest['sent_at'] ?? null, + 'failure_reason' => $latest['failure_reason'] ?? null, + ]; + } + + return $examples; + } + + public function sendForSchoolYearName(string $schoolYearName, bool $force = false): array + { + $schoolYear = $this->schoolYearByName($schoolYearName); + if ($schoolYear === null) { + return ['recipients' => 0, 'sent' => 0, 'failed' => 0, 'skipped' => 1, 'messages' => ['School year was not found.']]; + } + + return $this->sendForSchoolYear($schoolYear, null, false, $force); + } + + private function buildMessage(array $schoolYear, array $family): array + { + $schoolYearName = (string) ($schoolYear['name'] ?? ''); + $previousYear = $this->previousSchoolYearName($schoolYearName); + $deadline = $this->dateText($schoolYear['registration_deadline_at'] ?? $schoolYear['registration_ends_on'] ?? null); + $opens = $this->dateText($schoolYear['registration_opens_at'] ?? $schoolYear['registration_starts_on'] ?? null); + $students = []; + $studentIds = []; + + foreach ($family['students'] as $student) { + $studentId = (int) ($student['id'] ?? 0); + if ($studentId <= 0 || $previousYear === null) { + continue; + } + + $evaluation = $this->transitionService->evaluate($studentId, $previousYear, $schoolYearName, 'parent'); + $students[] = $this->studentSection($student, $evaluation, $opens, $deadline); + $studentIds[] = $studentId; + } + + $financial = $this->financialSection((int) $family['parent_user_id'], $schoolYear, $previousYear); + $subject = 'Registration for ' . $schoolYearName . ' Is Now Open'; + $bodyHtml = '
Dear ' . esc($family['name']) . ',
' + . 'We are pleased to welcome your family to the registration process for the ' . esc($schoolYearName) . ' school year.
' + . 'Registration opens on ' . esc($opens) . ' and closes on ' . esc($deadline) . '.
' + . 'Please review the information below for each of your children, including the deliberation decision, registration eligibility, expected academic placement, and any required action.
' + . implode('', $students) + . $financial + . 'To complete registration for each eligible student:
' + . 'Registration portal: ' . esc(site_url('parent/enroll_classes')) . '
' + . 'For assistance, please contact the school administration.
' + . 'Sincerely,
Al Rahma Sunday School
School Administration
Deliberation decision: ' . esc($decision ?: 'Pending') . '
'
+ . 'Registration status: ' . esc($status) . '
'
+ . 'Expected placement: ' . esc($placement) . '
' . esc($message) . '
' + . 'Required action: ' . esc($requiredAction) . '
'; + } + + private function decisionMessage(string $name, array $evaluation, string $opens, string $deadline): string + { + if ((string) ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::PASSED) { + $grade = $this->currentGradeText($evaluation); + + return 'We are pleased to inform you that ' . $name . ' has successfully passed ' . $grade . '. ' + . 'Re-enrollment for the new school year will open on ' . $opens . '. Please make sure to re-enroll your child before ' . $deadline . ' to secure their enrollment for the upcoming school year. ' + . 'To complete the process, sign in to the parent portal, review the student’s information, submit the required documents, acknowledge the school policies, and complete any applicable payment steps.'; + } + + if (($evaluation['blockers'] ?? []) !== []) { + return implode(' ', array_map('strval', $evaluation['blockers'])); + } + + return match ((string) ($evaluation['deliberation_decision'] ?? '')) { + DeliberationDecision::REPEAT_CLASS => 'The deliberation decision for ' . $name . ' is to repeat the current grade. After registration is completed, the student will remain in the same grade and class when available.', + DeliberationDecision::MAKE_UP_EXAM => 'The deliberation decision for ' . $name . ' is pending the result of a make-up exam. Registration may be completed now. The student will initially remain in the same grade.', + default => 'Please review the registration portal for the current enrollment status.', + }; + } + + private function financialSection(int $parentId, array $schoolYear, ?string $previousYear): string + { + $carry = $previousYear !== null ? $this->invoiceBalance($parentId, $previousYear) : 0.0; + $registrationFee = (float) ($schoolYear['registration_fee'] ?? 0); + $tuition = (float) ($schoolYear['tuition_due_at_registration'] ?? 0); + $mandatory = (float) ($schoolYear['mandatory_fees'] ?? 0); + $total = max(0, $carry) + $registrationFee + $tuition + $mandatory; + if ($total <= 0.0) { + return ''; + } + + $message = trim((string) ($schoolYear['financial_policy_message'] ?? '')); + if ($message === '') { + $message = 'The balance is shown for information and does not currently block registration.'; + } + + return 'Carry-over balance: $' . number_format($carry, 2) . '
'
+ . 'Registration fee: $' . number_format($registrationFee, 2) . '
'
+ . 'New-year tuition due now: $' . number_format($tuition, 2) . '
'
+ . 'Mandatory fees: $' . number_format($mandatory, 2) . '
'
+ . 'Total currently due: $' . number_format($total, 2) . '
' . esc($message) . '
'; + } + + private function recipientFamilies(array $schoolYear, ?string $testEmail): array + { + $builder = $this->db->table('users u') + ->select('u.id AS parent_user_id, u.firstname, u.lastname, u.email') + ->where('u.email IS NOT NULL') + ->where('u.email !=', ''); + + if ($this->db->fieldExists('user_type', 'users')) { + $builder->where('u.user_type', 'primary'); + } + + if ($testEmail !== null) { + $builder->limit(1); + } + + $families = []; + foreach ($builder->get()->getResultArray() as $row) { + $parentId = (int) ($row['parent_user_id'] ?? 0); + $students = $this->studentsForParent($parentId); + if ($students === []) { + continue; + } + + $email = $testEmail ?? strtolower(trim((string) ($row['email'] ?? ''))); + if (! filter_var($email, FILTER_VALIDATE_EMAIL)) { + continue; + } + + $name = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')) ?: 'Parent or Guardian'; + $families[$parentId] = [ + 'parent_user_id' => $parentId, + 'family_account_id' => $parentId, + 'name' => $name, + 'recipients' => [$email], + 'students' => $students, + ]; + } + + return array_values($families); + } + + private function studentsForParent(int $parentId): array + { + return $this->db->table('students') + ->select('id, firstname, lastname, dob') + ->where('parent_id', $parentId) + ->orderBy('lastname', 'ASC') + ->orderBy('firstname', 'ASC') + ->get() + ->getResultArray(); + } + + private function recordGenerated(array $schoolYear, array $family, array $message): int + { + $now = date('Y-m-d H:i:s'); + $this->db->table('enrollment_email_records')->insert([ + 'school_year' => (string) ($schoolYear['name'] ?? ''), + 'school_year_id' => (int) ($schoolYear['id'] ?? 0) ?: null, + 'family_account_id' => (int) ($family['family_account_id'] ?? 0) ?: null, + 'parent_user_id' => (int) ($family['parent_user_id'] ?? 0) ?: null, + 'recipient_addresses_json' => json_encode($message['recipients']), + 'template_version' => self::TEMPLATE_VERSION, + 'generated_subject' => $message['subject'], + 'generated_body' => $message['body'], + 'student_ids_included_json' => json_encode($message['student_ids']), + 'generated_at' => $now, + 'delivery_status' => 'generated', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + return (int) $this->db->insertID(); + } + + private function recordDelivery(int $recordId, bool $sent, ?string $failure): void + { + $this->db->table('enrollment_email_records') + ->where('id', $recordId) + ->update([ + 'delivery_status' => $sent ? 'sent' : 'failed', + 'sent_at' => $sent ? date('Y-m-d H:i:s') : null, + 'failure_reason' => $failure, + 'retry_count' => $sent ? 0 : 1, + 'updated_at' => date('Y-m-d H:i:s'), + ]); + } + + private function alreadySent(string $schoolYear, int $parentId): bool + { + if (! $this->db->tableExists('enrollment_email_records')) { + return false; + } + + return $this->db->table('enrollment_email_records') + ->where('school_year', $schoolYear) + ->where('parent_user_id', $parentId) + ->where('delivery_status', 'sent') + ->countAllResults() > 0; + } + + private function latestEmailRecord(string $schoolYear, int $parentId): ?array + { + if (! $this->db->tableExists('enrollment_email_records')) { + return null; + } + + return $this->db->table('enrollment_email_records') + ->select('delivery_status, sent_at, failure_reason') + ->where('school_year', $schoolYear) + ->where('parent_user_id', $parentId) + ->orderBy('created_at', 'DESC') + ->orderBy('id', 'DESC') + ->limit(1) + ->get() + ->getRowArray() ?: null; + } + + private function registrationYearsForDate(DateTimeInterface $date, bool $force): array + { + $builder = $this->db->table('school_years')->where('registration_starts_on IS NOT NULL'); + if (! $force) { + $builder->where('registration_starts_on', $date->format('Y-m-d')); + } + + return $builder->orderBy('registration_starts_on', 'ASC')->get()->getResultArray(); + } + + private function schoolYearByName(string $schoolYear): ?array + { + return $this->db->table('school_years')->where('name', $schoolYear)->limit(1)->get()->getRowArray() ?: null; + } + + private function invoiceBalance(int $parentId, string $schoolYear): float + { + if (! $this->db->tableExists('invoices')) { + return 0.0; + } + + $row = $this->db->table('invoices') + ->select('COALESCE(SUM(balance), 0) AS balance', false) + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->get() + ->getRowArray(); + + return round((float) ($row['balance'] ?? 0), 2); + } + + private function registrationStatus(array $evaluation): string + { + if ((string) ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::PASSED) { + return 'Eligible'; + } + + if (($evaluation['blockers'] ?? []) !== []) { + return ($evaluation['adult_student'] ?? false) ? 'Student Action Required' : 'Not Eligible'; + } + return ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM ? 'Eligible with pending placement' : 'Eligible'; + } + + private function placementText(array $evaluation): string + { + return match ((string) ($evaluation['placement_status'] ?? '')) { + 'automatic_distribution_pending' => $this->assignedGradeText($evaluation), + 'same_class_assigned' => 'Same grade and class', + 'temporary_same_grade' => 'Same grade initially', + 'manual_class_required' => 'Same grade - administrative class assignment required', + 'exit_required' => 'Completion or exit process required', + default => 'Pending', + }; + } + + private function requiredAction(array $evaluation, string $deadline): string + { + if ((string) ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::PASSED) { + return 'Complete re-enrollment before ' . $deadline . '.'; + } + + if (($evaluation['blockers'] ?? []) !== []) { + return ($evaluation['adult_student'] ?? false) ? 'Student must complete the authorized adult-student process or contact administration.' : 'Contact the school administration.'; + } + return ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM + ? 'Complete re-enrollment and follow the school instructions regarding the make-up exam.' + : 'Complete re-enrollment before ' . $deadline . '.'; + } + + private function previousSchoolYearName(string $schoolYear): ?string + { + return preg_match('/^(\d{4})-(\d{4})$/', $schoolYear, $m) ? ((int) $m[1] - 1) . '-' . ((int) $m[2] - 1) : null; + } + + private function dateText(mixed $value): string + { + $value = trim((string) $value); + if ($value === '') { + return 'the posted date'; + } + try { + return (new \DateTimeImmutable($value))->format('F j, Y'); + } catch (\Throwable) { + return $value; + } + } + + private function currentGradeText(array $evaluation): string + { + return $this->gradeText( + $evaluation['source_grade_name'] ?? $evaluation['source_class_section_name'] ?? null, + 'the current grade' + ); + } + + private function assignedGradeText(array $evaluation): string + { + return $this->gradeText( + $evaluation['assigned_grade_name'] ?? null, + 'Grade to be assigned' + ); + } + + private function gradeText(mixed $value, string $fallback): string + { + $grade = trim((string) $value); + + if ($grade === '') { + return $fallback; + } + + return preg_match('/^grade\b/i', $grade) === 1 ? $grade : 'Grade ' . $grade; + } +} diff --git a/app/Services/EnrollmentTransitionService.php b/app/Services/EnrollmentTransitionService.php new file mode 100644 index 0000000..05d615b --- /dev/null +++ b/app/Services/EnrollmentTransitionService.php @@ -0,0 +1,666 @@ +schoolYearByName($targetSchoolYear); + $sourceAssignment = $this->sourceAssignment($studentId, $sourceSchoolYear); + $student = $this->student($studentId); + $decisionRow = $this->decisionRow($studentId, $sourceSchoolYear); + $decision = DeliberationDecision::normalize($decisionRow['deliberation_decision_standard'] ?? null) + ?? DeliberationDecision::normalize($decisionRow['decision'] ?? null); + + $result = [ + 'student_id' => $studentId, + 'source_school_year' => $sourceSchoolYear, + 'target_school_year' => $targetSchoolYear, + 'deliberation_decision' => $decision, + 'decision_label' => DeliberationDecision::display($decisionRow['decision'] ?? ''), + 'source_grade_id' => $sourceAssignment['class_id'] ?? null, + 'source_grade_name' => $sourceAssignment['class_name'] ?? null, + 'source_class_section_id' => $sourceAssignment['class_section_id'] ?? null, + 'source_class_section_name' => $sourceAssignment['class_section_name'] ?? null, + 'assigned_grade_id' => null, + 'assigned_class_section_id' => null, + 'placement_status' => 'not_created', + 'academic_eligible' => false, + 'parent_enrollment_allowed' => false, + 'student_self_enrollment_allowed' => false, + 'administrative_enrollment_allowed' => true, + 'age_reference_date' => $this->ageReferenceDate($targetSchoolYear), + 'age_on_reference_date' => null, + 'adult_student' => false, + 'registration_window_status' => 'unknown', + 'blockers' => [], + 'warnings' => [], + 'flags' => [], + ]; + + if ($student === null) { + $result['blockers'][] = 'Student record was not found.'; + return $result; + } + + if ($sourceAssignment === null) { + $result['blockers'][] = 'Student does not belong to the closing school year.'; + return $result; + } + + if ($decisionRow === null || $decision === null) { + $result['blockers'][] = EnrollmentEligibility::MISSING_DECISION_MESSAGE; + $result['flags'][] = $this->flag('DEFERRED_DELIBERATION', 'high', [ + 'reason' => 'Missing or unrecognized final deliberation decision.', + ]); + return $result; + } + + if ($decision === DeliberationDecision::EXPELLED) { + $result['blockers'][] = EnrollmentEligibility::EXPELLED_MESSAGE; + $result['flags'][] = $this->flag('RESTRICTED_ADMINISTRATIVE_REVIEW', 'high'); + return $result; + } + + if ($decision === DeliberationDecision::WITHDRAWN) { + $result['blockers'][] = EnrollmentEligibility::WITHDRAWN_MESSAGE; + $result['flags'][] = $this->flag('WITHDRAWAL_REVIEW_REQUIRED', 'normal'); + return $result; + } + + if ($decision === DeliberationDecision::DEFERRED_DECISION) { + $result['blockers'][] = EnrollmentEligibility::DEFERRED_MESSAGE; + $result['flags'][] = $this->flag('DEFERRED_DELIBERATION', 'high'); + return $result; + } + + $placement = $this->placement($decision, $sourceAssignment, $targetSchoolYear); + $result = array_replace($result, $placement); + $result['academic_eligible'] = $placement['placement_status'] !== 'exit_required'; + + if ($placement['placement_status'] === 'exit_required') { + $result['blockers'][] = 'The student has passed the highest available grade and must follow the school completion or exit process.'; + $result['flags'][] = $this->flag('COMPLETION_OR_EXIT_PROCESS_REQUIRED', 'normal', [ + 'source_class' => $sourceAssignment['class_section_name'] ?? null, + ]); + } + + $age = EnrollmentEligibility::ageOnSeptemberFirst($student['dob'] ?? null, $targetSchoolYear); + $result['age_on_reference_date'] = $age; + $result['adult_student'] = $age !== null && $age >= 18; + $result['parent_enrollment_allowed'] = $result['academic_eligible']; + $result['student_self_enrollment_allowed'] = $result['academic_eligible'] && (bool) ($targetYear['adult_student_registration_enabled'] ?? false); + + if ($result['adult_student']) { + $result['parent_enrollment_allowed'] = false; + $result['flags'][] = $this->flag('ADULT_STUDENT_ACTION_REQUIRED', 'normal', [ + 'age_on_reference_date' => $age, + ]); + if ($actorRole === 'parent') { + $result['blockers'][] = str_replace('The student', $this->studentName($student), EnrollmentEligibility::ADULT_STUDENT_MESSAGE); + } + } + + $this->applyRegistrationWindow($result, $targetYear, $now, $actorRole); + $this->applyAgeRules($result, $targetSchoolYear, $age); + + if ($result['blockers'] === [] && $result['academic_eligible']) { + if ($actorRole === 'parent') { + $result['parent_enrollment_allowed'] = $result['parent_enrollment_allowed'] && ! $result['adult_student']; + } + } elseif ($actorRole === 'parent') { + $result['parent_enrollment_allowed'] = false; + } elseif ($actorRole === 'student') { + $result['student_self_enrollment_allowed'] = false; + } + + return $result; + } + + public function applyInitialTransition( + int $studentId, + string $sourceSchoolYear, + string $targetSchoolYear, + ?int $parentId, + ?int $performedBy = null, + string $actorRole = 'admin' + ): array { + $evaluation = $this->evaluate($studentId, $sourceSchoolYear, $targetSchoolYear, $actorRole); + if (! $evaluation['academic_eligible'] || $evaluation['blockers'] !== []) { + $this->writeFlags($evaluation, $performedBy); + $this->audit($evaluation, 'transition_evaluation_blocked', $performedBy, null, $evaluation); + return $evaluation; + } + + $this->db->transStart(); + $original = $this->latestEnrollment($studentId, $targetSchoolYear); + $student = $this->student($studentId); + $parentId = $parentId ?? (is_numeric($student['parent_id'] ?? null) ? (int) $student['parent_id'] : null); + + $payload = [ + 'student_id' => $studentId, + 'parent_id' => $parentId, + 'school_year' => $targetSchoolYear, + 'source_school_year' => $sourceSchoolYear, + 'deliberation_decision' => $evaluation['deliberation_decision'], + 'source_grade_id' => $evaluation['source_grade_id'], + 'assigned_grade_id' => $evaluation['assigned_grade_id'], + 'source_class_section_id' => $evaluation['source_class_section_id'], + 'assigned_class_section_id' => $evaluation['assigned_class_section_id'], + 'class_section_id' => $evaluation['assigned_class_section_id'], + 'placement_status' => $evaluation['placement_status'], + 'age_reference_date' => $evaluation['age_reference_date'], + 'age_on_reference_date' => $evaluation['age_on_reference_date'], + 'adult_student' => $evaluation['adult_student'] ? 1 : 0, + 'parent_enrollment_allowed' => $evaluation['parent_enrollment_allowed'] ? 1 : 0, + 'student_self_enrollment_allowed' => $evaluation['student_self_enrollment_allowed'] ? 1 : 0, + 'exception_required' => $evaluation['flags'] !== [] ? 1 : 0, + 'exception_reason' => $evaluation['flags'] !== [] ? implode(', ', array_column($evaluation['flags'], 'flag_type')) : null, + 'enrollment_date' => date('Y-m-d'), + 'enrollment_status' => 'admission under review', + 'admission_status' => 'pending', + 'updated_at' => date('Y-m-d H:i:s'), + ]; + + if ($original !== null) { + $this->db->table('enrollments')->where('id', (int) $original['id'])->update($payload); + } else { + $payload['created_at'] = date('Y-m-d H:i:s'); + $this->db->table('enrollments')->insert($payload); + } + + if ((int) ($evaluation['assigned_class_section_id'] ?? 0) > 0) { + $this->upsertStudentClass($studentId, (int) $evaluation['assigned_class_section_id'], $targetSchoolYear, $performedBy); + } + + $this->writeFlags($evaluation, $performedBy); + $this->audit($evaluation, 'initial_transition_applied', $performedBy, $original, $payload); + $this->db->transComplete(); + + if ($this->db->transStatus() === false) { + throw new RuntimeException('Unable to apply enrollment transition.'); + } + + return $evaluation; + } + + private function placement(string $decision, array $sourceAssignment, string $targetSchoolYear): array + { + $sourceClassId = (int) ($sourceAssignment['class_id'] ?? 0); + $sourceClassName = trim((string) ($sourceAssignment['class_name'] ?? $sourceAssignment['class_section_name'] ?? '')); + $sourceSectionName = trim((string) ($sourceAssignment['class_section_name'] ?? '')); + + if ($decision === DeliberationDecision::PASSED) { + $targetClass = $this->nextClass($sourceClassName, $targetSchoolYear); + return [ + 'assigned_grade_id' => $targetClass['id'] ?? null, + 'assigned_grade_name' => $targetClass['class_name'] ?? null, + 'assigned_class_section_id' => null, + 'placement_status' => $targetClass === null ? 'exit_required' : 'automatic_distribution_pending', + 'flags' => [], + ]; + } + + if ($decision === DeliberationDecision::REPEAT_CLASS) { + $targetSection = $this->matchingSection($sourceSectionName, $sourceClassId, $targetSchoolYear); + $flags = []; + if ($targetSection === null) { + $flags[] = $this->flag('CLASS_REASSIGNMENT_REQUIRED', 'normal', [ + 'previous_class_section_name' => $sourceSectionName, + ]); + } elseif ($this->sectionAtCapacity((int) $targetSection['class_section_id'])) { + $flags[] = $this->flag('CLASS_CAPACITY_EXCEPTION_REQUIRED', 'normal', [ + 'class_section_id' => (int) $targetSection['class_section_id'], + ]); + } + + return [ + 'assigned_grade_id' => $targetSection['class_id'] ?? $this->sameClassInTargetYear($sourceClassName, $targetSchoolYear)['id'] ?? ($sourceClassId ?: null), + 'assigned_grade_name' => $this->classNameForId((int) ($targetSection['class_id'] ?? 0)) ?? $sourceClassName, + 'assigned_class_section_id' => $targetSection['class_section_id'] ?? null, + 'placement_status' => $targetSection === null ? 'manual_class_required' : 'same_class_assigned', + 'flags' => $flags, + ]; + } + + if ($decision === DeliberationDecision::MAKE_UP_EXAM) { + $targetSection = $this->matchingSection($sourceSectionName, $sourceClassId, $targetSchoolYear); + return [ + 'assigned_grade_id' => $targetSection['class_id'] ?? $this->sameClassInTargetYear($sourceClassName, $targetSchoolYear)['id'] ?? ($sourceClassId ?: null), + 'assigned_grade_name' => $this->classNameForId((int) ($targetSection['class_id'] ?? 0)) ?? $sourceClassName, + 'assigned_class_section_id' => $targetSection['class_section_id'] ?? null, + 'placement_status' => $targetSection === null ? 'temporary_manual_class_required' : 'temporary_same_grade', + 'flags' => [ + $this->flag('PENDING_MAKE_UP_EXAM_PROMOTION', 'high', [ + 'current_grade_id' => $sourceClassId ?: null, + 'expected_promoted_grade_id' => $this->nextClass($sourceClassName, $targetSchoolYear)['id'] ?? null, + 'current_class_section_id' => $targetSection['class_section_id'] ?? null, + ]), + ], + ]; + } + + return ['assigned_grade_id' => null, 'assigned_class_section_id' => null, 'placement_status' => 'not_created', 'flags' => []]; + } + + private function applyRegistrationWindow(array &$result, ?array $targetYear, DateTimeImmutable $now, string $actorRole): void + { + if ($targetYear === null) { + $result['registration_window_status'] = 'missing_school_year'; + $result['blockers'][] = 'Target school year configuration was not found.'; + return; + } + + $opensAt = $this->dateTimeFromYear($targetYear, 'registration_opens_at', 'registration_starts_on', false); + $deadlineAt = $this->dateTimeFromYear($targetYear, 'registration_deadline_at', 'registration_ends_on', true); + + if ($opensAt !== null && $now < $opensAt) { + $result['registration_window_status'] = 'not_open'; + $result['blockers'][] = 'Registration for the new school year has not opened yet. Registration will be available starting on ' . $opensAt->format('F j, Y g:i A') . '.'; + return; + } + + if ($deadlineAt !== null && $now > $deadlineAt && (int) ($targetYear['late_registration_blocked'] ?? 1) === 1 && $actorRole !== 'admin') { + $result['registration_window_status'] = 'closed'; + $result['blockers'][] = 'The registration deadline was ' . $deadlineAt->format('F j, Y g:i A') . '. Online registration is no longer available. Please contact the school administration if you believe an exception applies.'; + $result['flags'][] = $this->flag('LATE_REGISTRATION_EXCEPTION', 'normal'); + return; + } + + $result['registration_window_status'] = 'open'; + } + + private function applyAgeRules(array &$result, string $targetSchoolYear, ?int $age): void + { + if ($age === null || ! $this->db->tableExists('enrollment_age_rules')) { + return; + } + + $builder = $this->db->table('enrollment_age_rules') + ->where('school_year', $targetSchoolYear) + ->groupStart() + ->where('grade_class_id', null) + ->orWhere('grade_class_id', $result['assigned_grade_id']) + ->groupEnd(); + + foreach ($builder->get()->getResultArray() as $rule) { + $min = is_numeric($rule['minimum_age'] ?? null) ? (int) $rule['minimum_age'] : null; + $max = is_numeric($rule['maximum_age'] ?? null) ? (int) $rule['maximum_age'] : null; + $violated = ($min !== null && $age < $min) || ($max !== null && $age > $max); + if (! $violated) { + continue; + } + + $message = 'Student age does not satisfy a configured age rule for the target placement.'; + if (($rule['behavior'] ?? 'blocking') === 'warning') { + $result['warnings'][] = $message; + } else { + $result['blockers'][] = $message; + $result['flags'][] = $this->flag('AGE_EXCEPTION_REQUIRED', 'normal', [ + 'age_rule_id' => (int) $rule['id'], + 'age_on_reference_date' => $age, + ]); + } + } + } + + private function sourceAssignment(int $studentId, string $sourceSchoolYear): ?array + { + if (! $this->db->tableExists('student_class')) { + return $this->sourceEnrollmentAssignment($studentId, $sourceSchoolYear); + } + + $assignment = $this->db->table('student_class sc') + ->select('sc.class_section_id, cs.class_section_name, cs.class_id, c.class_name') + ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') + ->join('classes c', 'c.id = cs.class_id', 'left') + ->where('sc.student_id', $studentId) + ->where('sc.school_year', $sourceSchoolYear) + ->where('sc.class_section_id IS NOT NULL', null, false) + ->orderBy('sc.updated_at', 'DESC') + ->orderBy('sc.id', 'DESC') + ->limit(1) + ->get() + ->getRowArray(); + + return $assignment ?: $this->sourceEnrollmentAssignment($studentId, $sourceSchoolYear); + } + + private function sourceEnrollmentAssignment(int $studentId, string $sourceSchoolYear): ?array + { + if (! $this->db->tableExists('enrollments')) { + return null; + } + + return $this->db->table('enrollments e') + ->select('e.class_section_id, cs.class_section_name, cs.class_id, c.class_name') + ->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left') + ->join('classes c', 'c.id = cs.class_id', 'left') + ->where('e.student_id', $studentId) + ->where('e.school_year', $sourceSchoolYear) + ->where('e.class_section_id IS NOT NULL', null, false) + ->orderBy('e.updated_at', 'DESC') + ->orderBy('e.id', 'DESC') + ->limit(1) + ->get() + ->getRowArray() ?: null; + } + + private function decisionRow(int $studentId, string $sourceSchoolYear): ?array + { + if (! $this->db->tableExists('student_decisions')) { + return null; + } + + $select = ['decision', 'source', 'notes', 'class_section_name']; + if ($this->db->fieldExists('deliberation_decision_standard', 'student_decisions')) { + $select[] = 'deliberation_decision_standard'; + } + + return $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() ?: null; + } + + private function student(int $studentId): ?array + { + return $this->db->table('students') + ->select('id, firstname, lastname, dob, parent_id') + ->where('id', $studentId) + ->limit(1) + ->get() + ->getRowArray() ?: null; + } + + private function schoolYearByName(string $schoolYear): ?array + { + if (! $this->db->tableExists('school_years')) { + return null; + } + + return $this->db->table('school_years')->where('name', $schoolYear)->limit(1)->get()->getRowArray() ?: null; + } + + private function nextClass(string $sourceClassName, string $targetSchoolYear): ?array + { + $base = $this->classBaseName($sourceClassName); + $target = match (true) { + $base === 'KG' || str_contains($base, 'KINDERGARTEN') => '1', + ctype_digit($base) => (string) ((int) $base + 1), + $base === 'YOUTH' => 'YOUTH', + default => '', + }; + + if ($target === '') { + return null; + } + + $targetClass = $this->classByName($target, $targetSchoolYear); + if ($targetClass !== null) { + return $targetClass; + } + + return ctype_digit($base) && (int) $base >= 9 + ? $this->classByName('YOUTH', $targetSchoolYear) + : null; + } + + private function sameClassInTargetYear(string $sourceClassName, string $targetSchoolYear): ?array + { + $base = $this->classBaseName($sourceClassName); + if ($base === '') { + return null; + } + + if (str_contains($base, 'KINDERGARTEN')) { + $base = 'KG'; + } + + return $this->classByName($base, $targetSchoolYear); + } + + private function classBaseName(string $className): string + { + $base = strtoupper(trim((string) preg_replace('/-.+$/', '', $className))); + $base = preg_replace('/\b(CLASS|GRADE)\b/i', '', $base) ?? $base; + $base = trim(preg_replace('/\s+/', ' ', $base) ?? $base); + + if (str_contains($base, 'KINDERGARTEN')) { + return 'KG'; + } + + if (preg_match('/\d+/', $base, $matches) === 1) { + return (string) (int) $matches[0]; + } + + return $base; + } + + private function classByName(string $className, string $schoolYear): ?array + { + $builder = $this->db->table('classes')->where('UPPER(class_name)', strtoupper($className)); + if ($this->db->fieldExists('school_year', 'classes')) { + $builder->where('school_year', $schoolYear); + } + + $row = $builder->orderBy('id', 'DESC')->limit(1)->get()->getRowArray(); + if ($row !== null || ! $this->db->fieldExists('school_year', 'classes')) { + return $row ?: null; + } + + return $this->db->table('classes') + ->where('UPPER(class_name)', strtoupper($className)) + ->orderBy('id', 'DESC') + ->limit(1) + ->get() + ->getRowArray() ?: null; + } + + private function classNameForId(int $classId): ?string + { + if ($classId <= 0 || ! $this->db->tableExists('classes')) { + return null; + } + + $row = $this->db->table('classes') + ->select('class_name') + ->where('id', $classId) + ->limit(1) + ->get() + ->getRowArray(); + + $name = trim((string) ($row['class_name'] ?? '')); + + return $name !== '' ? $name : null; + } + + private function matchingSection(string $sourceSectionName, int $sourceClassId, string $targetSchoolYear): ?array + { + $builder = $this->db->table('classSection')->select('class_section_id, class_id, class_section_name')->orderBy('id', 'DESC'); + if ($sourceSectionName !== '') { + $builder->where('class_section_name', $sourceSectionName); + } else { + $builder->where('class_id', $sourceClassId)->where("class_section_name NOT LIKE '%-%'", null, false); + } + + if ($this->db->fieldExists('school_year', 'classSection')) { + $builder->where('school_year', $targetSchoolYear); + } + + return $builder->limit(1)->get()->getRowArray() ?: null; + } + + private function sectionAtCapacity(int $classSectionId): bool + { + $section = $this->db->table('classSection cs') + ->select('cs.class_section_id, c.capacity') + ->join('classes c', 'c.id = cs.class_id', 'left') + ->where('cs.class_section_id', $classSectionId) + ->orderBy('cs.id', 'DESC') + ->limit(1) + ->get() + ->getRowArray(); + + $capacity = is_numeric($section['capacity'] ?? null) ? (int) $section['capacity'] : 0; + if ($capacity <= 0) { + return false; + } + + $count = $this->db->table('student_class')->where('class_section_id', $classSectionId)->countAllResults(); + return $count >= $capacity; + } + + private function latestEnrollment(int $studentId, string $schoolYear): ?array + { + if (! $this->db->tableExists('enrollments')) { + return null; + } + + return $this->db->table('enrollments') + ->where('student_id', $studentId) + ->where('school_year', $schoolYear) + ->orderBy('updated_at', 'DESC') + ->orderBy('id', 'DESC') + ->limit(1) + ->get() + ->getRowArray() ?: null; + } + + private function upsertStudentClass(int $studentId, int $classSectionId, string $schoolYear, ?int $performedBy): void + { + $existing = $this->db->table('student_class') + ->where('student_id', $studentId) + ->where('school_year', $schoolYear) + ->orderBy('id', 'DESC') + ->limit(1) + ->get() + ->getRowArray(); + + $payload = [ + 'student_id' => $studentId, + 'class_section_id' => $classSectionId, + 'school_year' => $schoolYear, + 'updated_by' => $performedBy, + 'updated_at' => date('Y-m-d H:i:s'), + ]; + + if ($existing !== null) { + $this->db->table('student_class')->where('id', (int) $existing['id'])->update($payload); + } else { + $payload['created_at'] = date('Y-m-d H:i:s'); + $this->db->table('student_class')->insert($payload); + } + } + + private function writeFlags(array $evaluation, ?int $performedBy): void + { + if (! $this->db->tableExists('enrollment_flags')) { + return; + } + + foreach ($evaluation['flags'] as $flag) { + $existing = $this->db->table('enrollment_flags') + ->where('student_id', (int) $evaluation['student_id']) + ->where('school_year', (string) $evaluation['target_school_year']) + ->where('flag_type', (string) $flag['flag_type']) + ->where('status', 'open') + ->limit(1) + ->get() + ->getRowArray(); + + if ($existing !== null) { + continue; + } + + $this->db->table('enrollment_flags')->insert([ + 'flag_type' => $flag['flag_type'], + 'student_id' => (int) $evaluation['student_id'], + 'school_year' => (string) $evaluation['target_school_year'], + 'source_school_year' => (string) $evaluation['source_school_year'], + 'status' => 'open', + 'priority' => $flag['priority'] ?? 'normal', + 'assigned_to' => $performedBy, + 'details_json' => json_encode($flag['details'] ?? [], JSON_UNESCAPED_SLASHES), + 'created_at' => date('Y-m-d H:i:s'), + ]); + } + } + + private function audit(array $evaluation, string $action, ?int $performedBy, ?array $original, array $new): void + { + if (! $this->db->tableExists('enrollment_transition_audits')) { + return; + } + + $this->db->table('enrollment_transition_audits')->insert([ + 'student_id' => (int) $evaluation['student_id'], + 'school_year' => (string) $evaluation['target_school_year'], + 'source_school_year' => (string) $evaluation['source_school_year'], + 'action' => $action, + 'performed_by' => $performedBy, + 'original_values_json' => $original !== null ? json_encode($original, JSON_UNESCAPED_SLASHES) : null, + 'new_values_json' => json_encode($new, JSON_UNESCAPED_SLASHES), + 'reason' => implode(' ', $evaluation['blockers'] ?? []), + 'created_at' => date('Y-m-d H:i:s'), + ]); + } + + private function flag(string $type, string $priority, array $details = []): array + { + return ['flag_type' => $type, 'priority' => $priority, 'details' => $details]; + } + + private function ageReferenceDate(string $schoolYear): string + { + return preg_match('/^(\d{4})/', $schoolYear, $matches) ? $matches[1] . '-09-01' : date('Y') . '-09-01'; + } + + private function dateTimeFromYear(array $year, string $dateTimeField, string $dateField, bool $endOfDay): ?DateTimeImmutable + { + $value = trim((string) ($year[$dateTimeField] ?? '')); + if ($value === '') { + $date = trim((string) ($year[$dateField] ?? '')); + $value = $date !== '' ? $date . ($endOfDay ? ' 23:59:59' : ' 00:00:00') : ''; + } + + if ($value === '') { + return null; + } + + try { + return new DateTimeImmutable($value); + } catch (\Throwable) { + return null; + } + } + + private function studentName(array $student): string + { + $name = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')); + return $name !== '' ? $name : 'The student'; + } +} diff --git a/app/Services/SchoolYearClosingService.php b/app/Services/SchoolYearClosingService.php index 7d654e7..0b4db28 100644 --- a/app/Services/SchoolYearClosingService.php +++ b/app/Services/SchoolYearClosingService.php @@ -7,6 +7,7 @@ use App\Models\SchoolYearClosingItemModel; use App\Models\SchoolYearModel; use App\Models\ConfigurationModel; use App\Models\InvoiceModel; +use App\Support\Enrollment\DeliberationDecision; use App\Support\SchoolYear\SchoolYearStatus; use CodeIgniter\Database\BaseConnection; use InvalidArgumentException; @@ -644,6 +645,7 @@ final class SchoolYearClosingService 'auto_kg_pass' => false, 'year_score' => null, 'decision' => '', + 'normalized_decision' => null, 'source' => 'missing', 'notes' => '', 'status' => 'missing', @@ -667,6 +669,7 @@ final class SchoolYearClosingService if ($decision !== null) { $student['year_score'] = $decision['year_score']; $student['decision'] = $decision['decision']; + $student['normalized_decision'] = $decision['normalized_decision']; $student['source'] = $decision['source']; $student['notes'] = $decision['notes']; $student['status'] = $decision['status']; @@ -676,17 +679,19 @@ final class SchoolYearClosingService } if ($decision === null && $this->isKgStudent($student)) { - $kgAgeStatus = $this->kgAgeStatusByTargetYearCutoff((string) $student['dob'], $targetSchoolYear); + $kgAgeStatus = $this->kgAgeStatusByTargetYearStartCutoff((string) $student['dob'], $targetSchoolYear); if ($kgAgeStatus === 'pass') { $student['decision'] = 'Pass'; + $student['normalized_decision'] = DeliberationDecision::PASSED; $student['source'] = 'automatic_kg_age'; - $student['notes'] = 'Auto-pass KG student: age 6 or older by Dec 31 of the next school year start year.'; + $student['notes'] = 'Auto-pass KG student: age 6 or older by Sep 1 of the next school year.'; $student['status'] = 'decided'; $student['auto_kg_pass'] = true; } elseif ($kgAgeStatus === 'keep_kg') { $student['decision'] = 'Keep KG'; + $student['normalized_decision'] = DeliberationDecision::REPEAT_CLASS; $student['source'] = 'automatic_kg_age'; - $student['notes'] = 'Auto-keep KG student: younger than 6 by Dec 31 of the next school year start year.'; + $student['notes'] = 'Auto-keep KG student: younger than 6 by Sep 1 of the next school year.'; $student['status'] = 'decided'; } } @@ -705,7 +710,7 @@ final class SchoolYearClosingService $summary['pending_decision']++; } else { $summary['with_decision']++; - if (strcasecmp((string) $student['decision'], 'Pass') === 0) { + if (($student['normalized_decision'] ?? null) === DeliberationDecision::PASSED) { $summary['pass']++; if ($queue === null && $student['auto_kg_pass'] !== true) { $summary['missing_queue']++; @@ -745,7 +750,7 @@ final class SchoolYearClosingService return false; } - private function kgAgeStatusByTargetYearCutoff(string $dob, ?string $targetSchoolYear): string + private function kgAgeStatusByTargetYearStartCutoff(string $dob, ?string $targetSchoolYear): string { $dob = trim($dob); if ($dob === '' || $targetSchoolYear === null || ! preg_match('/^(\d{4})-\d{4}$/', $targetSchoolYear, $matches)) { @@ -754,7 +759,7 @@ final class SchoolYearClosingService try { $birthDate = new \DateTimeImmutable($dob); - $cutoff = new \DateTimeImmutable($matches[1] . '-12-31'); + $cutoff = new \DateTimeImmutable($matches[1] . '-09-01'); } catch (\Throwable) { return ''; } @@ -808,11 +813,13 @@ final class SchoolYearClosingService $decision = trim((string) ($row['decision'] ?? '')); $source = trim((string) ($row['source'] ?? '')); $status = $decision === '' || $source === 'pending' ? 'pending' : 'decided'; + $normalizedDecision = DeliberationDecision::normalize($decision); $decisions[$studentId] = [ 'class_section_name' => (string) ($row['class_section_name'] ?? ''), 'year_score' => is_numeric($row['year_score'] ?? null) ? round((float) $row['year_score'], 2) : null, 'decision' => $decision, + 'normalized_decision' => $normalizedDecision, 'source' => $source !== '' ? $source : ($status === 'pending' ? 'pending' : 'manual'), 'notes' => (string) ($row['notes'] ?? ''), 'status' => $status, diff --git a/app/Services/SchoolYearContextService.php b/app/Services/SchoolYearContextService.php index 632b5e7..96ba6e4 100644 --- a/app/Services/SchoolYearContextService.php +++ b/app/Services/SchoolYearContextService.php @@ -22,7 +22,7 @@ final class SchoolYearContextService IncomingRequest $request, ?int $routeSchoolYearId = null ): SchoolYearContext { - $requestedId = $routeSchoolYearId ?? $this->normalizeInt($request->getGet('school_year_id')); + $requestedId = $routeSchoolYearId ?? $this->requestedSchoolYearId($request); $requestedName = $this->legacyYearName($request); $userId = (int) (session()->get('user_id') ?? 0); @@ -118,6 +118,27 @@ final class SchoolYearContextService return $context; } + public function forYearName(string $yearName): SchoolYearContext + { + $yearName = trim($yearName); + + if ($yearName === '') { + throw new SchoolYearNotFoundException('Selected school year was not found.'); + } + + $row = $this->schoolYearModel + ->where('name', $yearName) + ->first(); + + if ($row === null) { + throw new SchoolYearNotFoundException('Selected school year was not found.'); + } + + $userId = (int) (session()->get('user_id') ?? 0); + + return $this->authorizedContext($row, $userId, true); + } + public function clearSelection(): void { session()->remove('selected_school_year_id'); @@ -155,6 +176,31 @@ final class SchoolYearContextService return (int) $value; } + private function requestedSchoolYearId(IncomingRequest $request): ?int + { + $ids = []; + + foreach (['school_year_id', 'schoolYearId', 'year_id'] as $key) { + $value = $this->normalizeInt($request->getGet($key)); + if ($value !== null) { + $ids[$key] = $value; + } + + if ($this->isWriteRequest($request)) { + $value = $this->normalizeInt($request->getPost($key)); + if ($value !== null) { + $ids['post:' . $key] = $value; + } + } + } + + if (count(array_unique($ids)) > 1) { + throw new InvalidSchoolYearSelectionException('Conflicting school-year parameters were provided.'); + } + + return $ids === [] ? null : (int) reset($ids); + } + private function legacyYearName(IncomingRequest $request): string { $names = []; @@ -164,6 +210,13 @@ final class SchoolYearContextService if ($value !== '') { $names[$key] = $value; } + + if ($this->isWriteRequest($request)) { + $value = trim((string) ($request->getPost($key) ?? '')); + if ($value !== '') { + $names['post:' . $key] = $value; + } + } } if (count(array_unique($names)) > 1) { @@ -179,6 +232,11 @@ final class SchoolYearContextService return $names === [] ? '' : (string) reset($names); } + private function isWriteRequest(IncomingRequest $request): bool + { + return in_array(strtoupper($request->getMethod()), ['POST', 'PUT', 'PATCH', 'DELETE'], true); + } + private function authorizedContext(array $row, int $userId, bool $explicit): SchoolYearContext { if (! $this->isSelectable($row, $userId)) { diff --git a/app/Support/Enrollment/DeliberationDecision.php b/app/Support/Enrollment/DeliberationDecision.php new file mode 100644 index 0000000..0cc554f --- /dev/null +++ b/app/Support/Enrollment/DeliberationDecision.php @@ -0,0 +1,71 @@ + self::PASSED, + str_contains($compact, 'repeat') || str_contains($compact, 'keepkg') => self::REPEAT_CLASS, + str_contains($compact, 'makeupexam') || str_contains($compact, 'makeupexamfall') || str_contains($compact, 'makeup') => self::MAKE_UP_EXAM, + str_contains($compact, 'deferred') || str_contains($compact, 'pendingdecision') => self::DEFERRED_DECISION, + str_contains($compact, 'withdraw') || str_contains($compact, 'widthraw') || str_contains($compact, 'widthdraw') || str_contains($compact, 'widthrwan') => self::WITHDRAWN, + str_contains($compact, 'expel') => self::EXPELLED, + default => null, + }; + } + + public static function display(?string $decision): string + { + return match (self::normalize($decision) ?? $decision) { + self::PASSED => 'Passed', + self::REPEAT_CLASS => 'Repeat Class', + self::MAKE_UP_EXAM => 'Make-up Exam', + self::EXPELLED => 'Expelled', + self::WITHDRAWN => 'Withdrawn', + self::DEFERRED_DECISION => 'Deferred Decision', + default => trim((string) $decision), + }; + } + + public static function blocksEnrollment(?string $decision): bool + { + return in_array(self::normalize($decision), [ + self::EXPELLED, + self::WITHDRAWN, + self::DEFERRED_DECISION, + ], true); + } +} diff --git a/app/Support/Enrollment/EnrollmentEligibility.php b/app/Support/Enrollment/EnrollmentEligibility.php new file mode 100644 index 0000000..ebd5135 --- /dev/null +++ b/app/Support/Enrollment/EnrollmentEligibility.php @@ -0,0 +1,105 @@ + 0 || ($errors['error_count'] ?? 0) > 0)) + ) { + return null; + } + + $referenceDate = new \DateTimeImmutable($matches[1] . '-09-01'); + } catch (\Throwable) { + return null; + } + + return $birthDate > $referenceDate ? null : $birthDate->diff($referenceDate)->y; + } + + public static function parentDecisionMessage( + array $student, + ?array $decisionRow, + string $targetSchoolYear, + ?string $fallMakeupExamOn = null + ): array { + $name = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')); + $name = $name !== '' ? $name : 'The student'; + $decision = DeliberationDecision::normalize($decisionRow['decision'] ?? null); + $source = strtolower(trim((string) ($decisionRow['source'] ?? ''))); + + if ($decision === DeliberationDecision::EXPELLED) { + return self::message($name . ': ' . self::EXPELLED_MESSAGE, true, 'danger'); + } + + if ($decision === DeliberationDecision::WITHDRAWN || ($student['enrollment_status'] ?? null) === 'withdrawn') { + return self::message($name . ': ' . self::WITHDRAWN_MESSAGE, true, 'warning'); + } + + if ($decision === DeliberationDecision::DEFERRED_DECISION) { + return self::message($name . ': ' . self::DEFERRED_MESSAGE, true, 'warning'); + } + + if ($decision === null && $source === 'pending') { + return self::message($name . ': ' . self::MISSING_DECISION_MESSAGE, true, 'warning'); + } + + $age = self::ageOnSeptemberFirst($student['dob'] ?? null, $targetSchoolYear); + if ($age !== null && $age >= 18) { + return self::message(str_replace('The student', $name, self::ADULT_STUDENT_MESSAGE), true, 'danger'); + } + + if ($decision === DeliberationDecision::MAKE_UP_EXAM) { + $dateText = $fallMakeupExamOn !== null ? ' on ' . local_date($fallMakeupExamOn, 'm-d-Y') : ''; + return self::message( + $name . ' has a make-up exam decision. Enrollment is allowed, but the student will initially remain in the same grade as the closed school year until the fall make-up exam' . $dateText . ' is passed and administration confirms promotion.', + false, + 'warning' + ); + } + + if ($decision === DeliberationDecision::REPEAT_CLASS) { + $previousClass = trim((string) ($decisionRow['class_section_name'] ?? '')); + $previousClass = $previousClass !== '' ? $previousClass : 'the same class'; + + return self::message($name . ' has a repeat class decision and is accepted only in ' . $previousClass . '.', false, 'info'); + } + + if ($decision === DeliberationDecision::PASSED || ($decision === null && $source !== 'pending' && trim((string) ($decisionRow['decision'] ?? '')) === '')) { + return self::message('', false, 'info'); + } + + return self::message( + $name . ' does not have a final academic decision that permits online re-enrollment. Please contact the administration.', + true, + 'warning' + ); + } + + private static function message(string $message, bool $blocking, string $level): array + { + return [ + 'message' => $message, + 'blocking' => $blocking, + 'level' => $level, + ]; + } +} diff --git a/app/Views/administrator/enrollment_admin_dashboard.php b/app/Views/administrator/enrollment_admin_dashboard.php new file mode 100644 index 0000000..d8888fd --- /dev/null +++ b/app/Views/administrator/enrollment_admin_dashboard.php @@ -0,0 +1,336 @@ += $this->extend('layout/management_layout') ?> += $this->section('content') ?> + +| Parent | +Recipient | +Students | +Subject | +Delivery | +Example | +
|---|---|---|---|---|---|
| = esc($example['parent_name'] ?? '') ?> | += esc(implode(', ', $example['recipients'] ?? [])) ?> | += esc(implode(', ', $example['student_names'] ?? [])) ?> | += esc($example['subject'] ?? '') ?> | +
+ = esc($deliveryStatus) ?>
+
+ = esc(local_datetime($example['sent_at'], 'm-d-Y H:i')) ?>
+
+
+ = esc($example['failure_reason']) ?>
+
+ |
+ + View Example + | +
| No parent decision email examples found for this school year. | +|||||
| Student | +School ID | +Enrollment Status | +Decision | +Placement | +Class | +Exception / Reason | +Updated | +
|---|---|---|---|---|---|---|---|
| = esc($row['student_name'] ?? '') ?> | += esc($row['school_id'] ?? '') ?> | += esc($statusValue) ?> | += esc($decisionValue !== '' ? $decisionValue : 'Pending') ?> | += esc($placementValue !== '' ? $placementValue : 'Pending') ?> | += esc($row['class_section_name'] ?? '') ?> | +
+
+ Exception
+
+ = esc($row['exception_reason'] ?? '') ?>
+ |
+ = esc(!empty($row['updated_at']) ? local_datetime($row['updated_at'], 'm-d-Y H:i') : '') ?> | +
| No enrollment records need follow-up for this school year. | +|||||||
| Student | +School ID | +Flag Type | +Priority | +Details | +Assigned | +Created | +Action | +
|---|---|---|---|---|---|---|---|
| = esc($flag['student_name'] ?? '') ?> | += esc($flag['school_id'] ?? '') ?> | += esc($flagTypeValue) ?> | += esc($flag['priority'] ?? 'normal') ?> | +
+
+ $value): ?>
+ = esc((string) $key) ?>: = esc(is_scalar($value) ? (string) $value : json_encode($value)) ?>
+
+
+ No details
+
+ |
+ = esc($flag['assignee_name'] ?: '') ?> | += esc(!empty($flag['created_at']) ? local_datetime($flag['created_at'], 'm-d-Y H:i') : '') ?> | +
+
+
+
+
+
+
+
+
+
+
+
+ = esc($flag['resolution_notes'] ?? '') ?>
+
+ |
+
| No formal enrollment flags found. Check Enrollment Follow-up above for status-based items. | +|||||||
| When | +Student | +Action | +Administrator | +Reason | +
|---|---|---|---|---|
| = esc(!empty($row['created_at']) ? local_datetime($row['created_at'], 'm-d-Y H:i') : '') ?> | += esc($row['student_name'] ?? '') ?> | += esc($row['action'] ?? '') ?> | += esc($row['performed_by_name'] ?? '') ?> | += esc($row['reason'] ?? '') ?> | +
| No enrollment audit records found. Audit rows appear after actions are completed from this dashboard or through the transition service. | +||||
| Class | Total | -Sections | -Actions | Distribution | Registration Date | Parent/Guardian | Student Name | +Age | New Student | Removed (Prior Years) | Current Class | @@ -78,6 +79,11 @@ + ++ = isset($student['age']) && $student['age'] !== '' && $student['age'] !== null ? esc((string)(int)$student['age']) : '-' ?> + | +@@ -109,6 +115,9 @@ case 'admission under review': echo 'admission under review'; break; + case 'review & decision': + echo 'review & decision'; + break; case 'payment pending': echo 'payment pending'; break; @@ -145,6 +154,7 @@ data-parent-id="= $pid ?>" data-prev="= esc($student['enrollment_status'] ?? '') ?>"> + @@ -155,9 +165,9 @@ | - +- + |
|---|