This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
|
||||
class EnrollmentPostLaunchMonitor extends BaseCommand
|
||||
{
|
||||
protected $group = 'Registration';
|
||||
protected $name = 'registration:monitor';
|
||||
protected $description = 'Monitor post-launch enrollment progress, blocks, flags, and email delivery.';
|
||||
protected $usage = 'php spark registration:monitor [--school-year=2026-2027] [--days=7] [--json]';
|
||||
protected $options = [
|
||||
'--school-year' => '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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
|
||||
class EnrollmentReleaseAudit extends BaseCommand
|
||||
{
|
||||
protected $group = 'Registration';
|
||||
protected $name = 'registration:release-audit';
|
||||
protected $description = 'Audit school-year transition and registration readiness before launch.';
|
||||
protected $usage = 'php spark registration:release-audit [--school-year=2026-2027] [--json]';
|
||||
protected $options = [
|
||||
'--school-year' => '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');
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user