guessNextSchoolYear(); $query = StudentPromotionRecord::query()->forParent($parentId); if ($year !== null) { $query->forNextYear($year); } $records = $query ->orderByRaw('CASE WHEN promotion_status IN (?, ?, ?) THEN 0 ELSE 1 END', [ StudentPromotionRecord::STATUS_AWAITING_PARENT, StudentPromotionRecord::STATUS_ELIGIBLE, StudentPromotionRecord::STATUS_ENROLLMENT_STARTED, ]) ->orderBy('promotion_id', 'desc') ->get(); $studentIds = $records->pluck('student_id')->unique()->all(); /** @var EloquentCollection $studentsCollection */ $studentsCollection = ! empty($studentIds) ? Student::query()->whereIn('id', $studentIds)->get() : new EloquentCollection; $studentsById = $studentsCollection->keyBy('id'); $payload = $records->map(function (StudentPromotionRecord $record) use ($studentsById) { return $this->presentRecord($record, $studentsById->get($record->student_id)); })->all(); return [ 'records' => $payload, 'next_school_year' => $year, 'message' => $this->parentBannerMessage($records), ]; } /** * Mark the start of the enrollment process for a single promotion * record. */ public function startEnrollment(StudentPromotionRecord $record, int $parentId, ?int $userId = null): StudentPromotionRecord { $this->assertParentOwns($record, $parentId); $this->assertActionable($record); return DB::transaction(function () use ($record, $parentId, $userId) { if (! $record->enrollment_started_at) { $record->enrollment_started_at = now(); } $record->enrollment_status = StudentPromotionRecord::ENROLLMENT_IN_PROGRESS; $record->parent_id = $record->parent_id ?: $parentId; $record->updated_by = $userId; $record->save(); if ($record->promotion_status !== StudentPromotionRecord::STATUS_ENROLLMENT_STARTED) { $this->statusService->transition( $record, StudentPromotionRecord::STATUS_ENROLLMENT_STARTED, $userId, 'Parent started enrollment' ); } $this->audit->logEnrollmentStarted($record, $userId); return $record->refresh(); }); } /** * Update one or more checklist steps. Returns the (possibly * finalised) record. */ public function updateSteps( StudentPromotionRecord $record, int $parentId, array $steps, ?int $userId = null ): StudentPromotionRecord { $this->assertParentOwns($record, $parentId); $this->assertActionable($record); $sanitized = []; foreach ($steps as $field => $value) { if (! in_array($field, self::ALLOWED_STEPS, true)) { continue; } $sanitized[$field] = (bool) $value; } if (empty($sanitized)) { return $record; } return DB::transaction(function () use ($record, $parentId, $sanitized, $userId) { $changed = []; foreach ($sanitized as $field => $value) { if ((bool) $record->{$field} !== $value) { $record->{$field} = $value; $changed[] = $field; } } if (empty($changed)) { return $record; } if (! $record->enrollment_started_at) { $record->enrollment_started_at = now(); } $record->enrollment_status = StudentPromotionRecord::ENROLLMENT_IN_PROGRESS; $record->parent_id = $record->parent_id ?: $parentId; $record->updated_by = $userId; $record->save(); foreach ($changed as $field) { $this->audit->logEnrollmentStepCompleted($record, $field, $userId); } if ($record->promotion_status !== StudentPromotionRecord::STATUS_ENROLLMENT_STARTED) { if (in_array($record->promotion_status, [ StudentPromotionRecord::STATUS_ELIGIBLE, StudentPromotionRecord::STATUS_AWAITING_PARENT, ], true)) { $this->statusService->transition( $record, StudentPromotionRecord::STATUS_ENROLLMENT_STARTED, $userId, 'Enrollment progress recorded' ); } } // Auto-finalise once all checklist items are complete. if ($record->enrollmentChecklistComplete()) { $record = $this->finalize($record, $userId); } return $record; }); } /** * Submit the enrollment, asserting the checklist is complete. */ public function submitEnrollment( StudentPromotionRecord $record, int $parentId, ?int $userId = null ): StudentPromotionRecord { $this->assertParentOwns($record, $parentId); $this->assertActionable($record); if (! $record->enrollmentChecklistComplete()) { throw new RuntimeException('Enrollment checklist is incomplete.'); } return $this->finalize($record, $userId); } /** * Mark expired records (past deadline, still incomplete) as * "not_enrolled_for_next_year" (plan section 15). * * @return array{ expired:int, ids:array } */ public function markExpiredDeadlines(?\DateTimeInterface $now = null, ?int $userId = null): array { $now = $now ?: now(); $records = StudentPromotionRecord::query() ->whereIn('promotion_status', [ StudentPromotionRecord::STATUS_ELIGIBLE, StudentPromotionRecord::STATUS_AWAITING_PARENT, StudentPromotionRecord::STATUS_ENROLLMENT_STARTED, ]) ->whereNotNull('enrollment_deadline') ->whereDate('enrollment_deadline', '<', $now) ->get(); $ids = []; foreach ($records as $record) { DB::transaction(function () use ($record, $userId, &$ids) { $record->enrollment_status = StudentPromotionRecord::ENROLLMENT_EXPIRED; $record->save(); $this->audit->logDeadlineExpired($record); $this->statusService->forceStatus( $record, StudentPromotionRecord::STATUS_NOT_ENROLLED, $userId, 'Deadline expired without enrollment' ); $ids[] = (int) $record->getKey(); }); } return [ 'expired' => count($ids), 'ids' => $ids, ]; } /** * Finalise promotion + create the next-year enrollment record * (plan sections 5, 6 step 6 and section 9). */ private function finalize(StudentPromotionRecord $record, ?int $userId): StudentPromotionRecord { if ($record->promotion_status === StudentPromotionRecord::STATUS_PROMOTED_AND_ENROLLED) { return $record; } return DB::transaction(function () use ($record, $userId) { $record->enrollment_status = StudentPromotionRecord::ENROLLMENT_COMPLETED; $record->enrollment_completed_at = $record->enrollment_completed_at ?: now(); $record->promotion_finalized_at = now(); $record->updated_by = $userId; $enrollmentId = $this->ensureNextYearEnrollment($record); if ($enrollmentId !== null) { $record->enrollment_id = $enrollmentId; } $record->save(); $this->audit->logEnrollmentCompleted($record, $userId); $this->statusService->transition( $record, StudentPromotionRecord::STATUS_PROMOTED_AND_ENROLLED, $userId, 'Enrollment checklist complete' ); $this->audit->logPromotionFinalized($record->refresh(), $userId); return $record->refresh(); }); } /** * Create or update an `enrollments` row for the next school year so * the student is officially placed in the new year (plan section 9 * record-update rule). */ private function ensureNextYearEnrollment(StudentPromotionRecord $record): ?int { $existing = Enrollment::query() ->where('student_id', $record->student_id) ->where('school_year', $record->next_school_year) ->first(); $payload = [ 'student_id' => $record->student_id, 'parent_id' => $record->parent_id, 'school_year' => $record->next_school_year, 'enrollment_status' => 'enrolled', 'admission_status' => 'accepted', 'is_withdrawn' => false, 'enrollment_date' => now()->toDateString(), ]; if ($existing) { $existing->fill($payload); $existing->save(); return (int) $existing->getKey(); } $created = Enrollment::query()->create($payload); return $created ? (int) $created->getKey() : null; } private function presentRecord(StudentPromotionRecord $record, ?Student $student): array { return [ 'promotion_id' => (int) $record->getKey(), 'student_id' => (int) $record->student_id, 'student_name' => $student ? trim((string) $student->firstname.' '.(string) $student->lastname) : null, 'current_level' => $record->current_level_name, 'promoted_level' => $record->promoted_level_name, 'current_school_year' => $record->current_school_year, 'next_school_year' => $record->next_school_year, 'promotion_status' => $record->promotion_status, 'enrollment_status' => $record->enrollment_status, 'enrollment_deadline' => $record->enrollment_deadline?->toDateString(), 'enrollment_started_at' => $record->enrollment_started_at?->toDateTimeString(), 'enrollment_completed_at' => $record->enrollment_completed_at?->toDateTimeString(), 'parent_action_required' => in_array( $record->promotion_status, StudentPromotionRecord::parentActionableStatuses(), true ), 'checklist' => [ 'info_confirmed' => (bool) $record->info_confirmed, 'documents_uploaded' => (bool) $record->documents_uploaded, 'agreement_accepted' => (bool) $record->agreement_accepted, 'payment_completed' => (bool) $record->payment_completed, ], 'final_average' => $record->final_average !== null ? (float) $record->final_average : null, 'passed_current_level' => $record->passed_current_level, ]; } private function parentBannerMessage(EloquentCollection $records): ?string { $actionable = $records->first(function (StudentPromotionRecord $r) { return in_array( $r->promotion_status, StudentPromotionRecord::parentActionableStatuses(), true ); }); if (! $actionable) { return null; } $level = $actionable->promoted_level_name ?: 'the next level'; $deadline = $actionable->enrollment_deadline?->toDateString(); $deadlineText = $deadline ? 'by '.$deadline : 'as soon as possible'; return sprintf( 'Your child is eligible for promotion to %s. Please complete enrollment for the new school year %s.', $level, $deadlineText ); } private function assertParentOwns(StudentPromotionRecord $record, int $parentId): void { if ($parentId <= 0) { throw new RuntimeException('Authenticated parent is required.'); } if ((int) $record->parent_id !== $parentId) { $studentParent = (int) (Student::query()->where('id', $record->student_id)->value('parent_id') ?? 0); if ($studentParent !== $parentId) { throw new RuntimeException('You are not authorised to act on this promotion record.'); } } } private function assertActionable(StudentPromotionRecord $record): void { $actionable = StudentPromotionRecord::parentActionableStatuses(); if (! in_array($record->promotion_status, $actionable, true)) { throw new RuntimeException(sprintf( 'Promotion record is not actionable in status "%s".', $record->promotion_status )); } } private function guessNextSchoolYear(): ?string { $current = (string) (Configuration::getConfigValueByKey('school_year') ?? ''); if (! preg_match('/^(\d{4})-(\d{4})$/', $current, $m)) { return null; } return ((int) $m[1] + 1).'-'.((int) $m[2] + 1); } }