324 lines
12 KiB
PHP
324 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Commands;
|
|
|
|
use CodeIgniter\CLI\BaseCommand;
|
|
use CodeIgniter\CLI\CLI;
|
|
use CodeIgniter\Database\BaseConnection;
|
|
|
|
class EnrollmentCloseoutReport extends BaseCommand
|
|
{
|
|
protected $group = 'Registration';
|
|
protected $name = 'registration:closeout-report';
|
|
protected $description = 'Generate end-of-registration reconciliation and closeout exception lists.';
|
|
protected $usage = 'php spark registration:closeout-report [--school-year=2026-2027] [--json] [--export=/tmp/registration-closeout.csv]';
|
|
protected $options = [
|
|
'--school-year' => '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');
|
|
}
|
|
}
|
|
}
|