Files
alrahma_sunday_school/app/Services/EnrollmentTransitionService.php
T
root 1ef2800f12
Tests / PHPUnit (push) Successful in 1m26s
fix enrollment for the new school-year
2026-08-07 23:43:31 -04:00

667 lines
28 KiB
PHP

<?php
namespace App\Services;
use App\Support\Enrollment\DeliberationDecision;
use App\Support\Enrollment\EnrollmentEligibility;
use CodeIgniter\Database\BaseConnection;
use DateTimeImmutable;
use DateTimeInterface;
use RuntimeException;
final class EnrollmentTransitionService
{
public function __construct(private readonly BaseConnection $db)
{
}
public function evaluate(
int $studentId,
string $sourceSchoolYear,
string $targetSchoolYear,
string $actorRole = 'parent',
?DateTimeInterface $now = null
): array {
$now = $now !== null ? DateTimeImmutable::createFromInterface($now) : new DateTimeImmutable('now');
$targetYear = $this->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';
}
}