'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'); } } }