fix enrollment for the new school-year
Tests / PHPUnit (push) Successful in 1m26s

This commit is contained in:
root
2026-08-07 23:43:31 -04:00
parent b6f3b14e7b
commit 1ef2800f12
66 changed files with 8997 additions and 498 deletions
@@ -0,0 +1,486 @@
<?php
namespace App\Services;
use App\Support\Enrollment\DeliberationDecision;
use CodeIgniter\Database\BaseConnection;
use DateTimeInterface;
final class EnrollmentRegistrationEmailService
{
public const TEMPLATE_VERSION = 'phase5_consolidated_v1';
public function __construct(
private readonly BaseConnection $db,
private readonly EnrollmentTransitionService $transitionService,
private readonly EmailService $emailService,
) {
}
public function sendForDate(DateTimeInterface $date, bool $force = false, ?string $testEmail = null, bool $dryRun = false): array
{
$summary = [
'school_years' => 0,
'recipients' => 0,
'sent' => 0,
'failed' => 0,
'skipped' => 0,
'dry_run' => $dryRun,
'messages' => [],
];
foreach ($this->registrationYearsForDate($date, $force) as $schoolYear) {
$summary['school_years']++;
if (! $dryRun && empty($schoolYear['registration_launch_approved_at'])) {
$summary['messages'][] = 'Registration launch is not approved for ' . (string) ($schoolYear['name'] ?? '') . '.';
$summary['skipped']++;
continue;
}
$result = $this->sendForSchoolYear($schoolYear, $testEmail, $dryRun, $force);
foreach (['recipients', 'sent', 'failed', 'skipped'] as $key) {
$summary[$key] += $result[$key] ?? 0;
}
array_push($summary['messages'], ...($result['messages'] ?? []));
}
return $summary;
}
public function sendForSchoolYear(array $schoolYear, ?string $testEmail = null, bool $dryRun = false, bool $force = false): array
{
$summary = ['recipients' => 0, 'sent' => 0, 'failed' => 0, 'skipped' => 0, 'messages' => []];
if (! $dryRun && empty($schoolYear['registration_launch_approved_at'])) {
$summary['messages'][] = 'Registration launch is not approved for ' . (string) ($schoolYear['name'] ?? '') . '.';
$summary['skipped']++;
return $summary;
}
$families = $this->recipientFamilies($schoolYear, $testEmail);
if ($families === []) {
$summary['messages'][] = 'No recipient families found for ' . (string) ($schoolYear['name'] ?? '') . '.';
return $summary;
}
foreach ($families as $family) {
if ($testEmail === null && ! $force && $this->alreadySent((string) $schoolYear['name'], (int) $family['parent_user_id'])) {
$summary['skipped']++;
continue;
}
$message = $this->buildMessage($schoolYear, $family);
if ($testEmail !== null) {
$message['subject'] = '[TEST] ' . $message['subject'];
$message['recipients'] = [$testEmail];
}
$summary['recipients']++;
$recordId = $dryRun || ! $this->db->tableExists('enrollment_email_records') ? null : $this->recordGenerated($schoolYear, $family, $message);
$sent = $dryRun || $this->emailService->send($message['recipients'][0], $message['subject'], $message['body'], 'general');
$sent ? $summary['sent']++ : $summary['failed']++;
if (! $dryRun && $recordId !== null) {
$this->recordDelivery($recordId, $sent, $sent ? null : 'Email send failed');
}
}
return $summary;
}
public function previewForParent(string $schoolYearName, int $parentId): ?array
{
$schoolYear = $this->schoolYearByName($schoolYearName);
if ($schoolYear === null) {
return null;
}
foreach ($this->recipientFamilies($schoolYear, null) as $family) {
if ((int) $family['parent_user_id'] === $parentId) {
return $this->buildMessage($schoolYear, $family);
}
}
return null;
}
public function previewExamplesForSchoolYear(string $schoolYearName): array
{
$schoolYear = $this->schoolYearByName($schoolYearName);
if ($schoolYear === null) {
return [];
}
$examples = [];
foreach ($this->recipientFamilies($schoolYear, null) as $family) {
$message = $this->buildMessage($schoolYear, $family);
$latest = $this->latestEmailRecord($schoolYearName, (int) $family['parent_user_id']);
$examples[] = [
'parent_user_id' => (int) $family['parent_user_id'],
'parent_name' => (string) $family['name'],
'recipients' => $message['recipients'],
'student_names' => array_map(
static fn (array $student): string => trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')) ?: 'Student #' . (int) ($student['id'] ?? 0),
$family['students']
),
'subject' => (string) $message['subject'],
'delivery_status' => (string) ($latest['delivery_status'] ?? 'not sent'),
'sent_at' => $latest['sent_at'] ?? null,
'failure_reason' => $latest['failure_reason'] ?? null,
];
}
return $examples;
}
public function sendForSchoolYearName(string $schoolYearName, bool $force = false): array
{
$schoolYear = $this->schoolYearByName($schoolYearName);
if ($schoolYear === null) {
return ['recipients' => 0, 'sent' => 0, 'failed' => 0, 'skipped' => 1, 'messages' => ['School year was not found.']];
}
return $this->sendForSchoolYear($schoolYear, null, false, $force);
}
private function buildMessage(array $schoolYear, array $family): array
{
$schoolYearName = (string) ($schoolYear['name'] ?? '');
$previousYear = $this->previousSchoolYearName($schoolYearName);
$deadline = $this->dateText($schoolYear['registration_deadline_at'] ?? $schoolYear['registration_ends_on'] ?? null);
$opens = $this->dateText($schoolYear['registration_opens_at'] ?? $schoolYear['registration_starts_on'] ?? null);
$students = [];
$studentIds = [];
foreach ($family['students'] as $student) {
$studentId = (int) ($student['id'] ?? 0);
if ($studentId <= 0 || $previousYear === null) {
continue;
}
$evaluation = $this->transitionService->evaluate($studentId, $previousYear, $schoolYearName, 'parent');
$students[] = $this->studentSection($student, $evaluation, $opens, $deadline);
$studentIds[] = $studentId;
}
$financial = $this->financialSection((int) $family['parent_user_id'], $schoolYear, $previousYear);
$subject = 'Registration for ' . $schoolYearName . ' Is Now Open';
$bodyHtml = '<p>Dear ' . esc($family['name']) . ',</p>'
. '<p>We are pleased to welcome your family to the registration process for the ' . esc($schoolYearName) . ' school year.</p>'
. '<p>Registration opens on ' . esc($opens) . ' and closes on ' . esc($deadline) . '.</p>'
. '<p>Please review the information below for each of your children, including the deliberation decision, registration eligibility, expected academic placement, and any required action.</p>'
. implode('', $students)
. $financial
. '<p>To complete registration for each eligible student:</p>'
. '<ol><li>Sign in to the parent portal.</li><li>Review and update student information.</li><li>Upload required documents.</li><li>Review and acknowledge school policies.</li><li>Review tuition, fees, and any carry-over balance.</li><li>Submit registration before ' . esc($deadline) . '.</li></ol>'
. '<p>Registration portal: <a href="' . esc(site_url('parent/enroll_classes')) . '">' . esc(site_url('parent/enroll_classes')) . '</a></p>'
. '<p>For assistance, please contact the school administration.</p>'
. '<p>Sincerely,<br>Al Rahma Sunday School<br>School Administration</p>';
$body = view('emails/_wrap_layout', [
'title' => $subject,
'body_html' => $bodyHtml,
], ['saveData' => true]);
return [
'subject' => $subject,
'body' => $body,
'recipients' => $family['recipients'],
'student_ids' => array_values(array_unique($studentIds)),
];
}
private function studentSection(array $student, array $evaluation, string $opens, string $deadline): string
{
$name = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')) ?: 'Student';
$decision = DeliberationDecision::display((string) ($evaluation['deliberation_decision'] ?? ''));
$status = $this->registrationStatus($evaluation);
$placement = $this->placementText($evaluation);
$requiredAction = $this->requiredAction($evaluation, $deadline);
$message = $this->decisionMessage($name, $evaluation, $opens, $deadline);
return '<h3>' . esc($name) . '</h3>'
. '<p><strong>Deliberation decision:</strong> ' . esc($decision ?: 'Pending') . '<br>'
. '<strong>Registration status:</strong> ' . esc($status) . '<br>'
. '<strong>Expected placement:</strong> ' . esc($placement) . '</p>'
. '<p>' . esc($message) . '</p>'
. '<p><strong>Required action:</strong> ' . esc($requiredAction) . '</p>';
}
private function decisionMessage(string $name, array $evaluation, string $opens, string $deadline): string
{
if ((string) ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::PASSED) {
$grade = $this->currentGradeText($evaluation);
return 'We are pleased to inform you that ' . $name . ' has successfully passed ' . $grade . '. '
. 'Re-enrollment for the new school year will open on ' . $opens . '. Please make sure to re-enroll your child before ' . $deadline . ' to secure their enrollment for the upcoming school year. '
. 'To complete the process, sign in to the parent portal, review the students information, submit the required documents, acknowledge the school policies, and complete any applicable payment steps.';
}
if (($evaluation['blockers'] ?? []) !== []) {
return implode(' ', array_map('strval', $evaluation['blockers']));
}
return match ((string) ($evaluation['deliberation_decision'] ?? '')) {
DeliberationDecision::REPEAT_CLASS => 'The deliberation decision for ' . $name . ' is to repeat the current grade. After registration is completed, the student will remain in the same grade and class when available.',
DeliberationDecision::MAKE_UP_EXAM => 'The deliberation decision for ' . $name . ' is pending the result of a make-up exam. Registration may be completed now. The student will initially remain in the same grade.',
default => 'Please review the registration portal for the current enrollment status.',
};
}
private function financialSection(int $parentId, array $schoolYear, ?string $previousYear): string
{
$carry = $previousYear !== null ? $this->invoiceBalance($parentId, $previousYear) : 0.0;
$registrationFee = (float) ($schoolYear['registration_fee'] ?? 0);
$tuition = (float) ($schoolYear['tuition_due_at_registration'] ?? 0);
$mandatory = (float) ($schoolYear['mandatory_fees'] ?? 0);
$total = max(0, $carry) + $registrationFee + $tuition + $mandatory;
if ($total <= 0.0) {
return '';
}
$message = trim((string) ($schoolYear['financial_policy_message'] ?? ''));
if ($message === '') {
$message = 'The balance is shown for information and does not currently block registration.';
}
return '<h3>Family Account Information</h3>'
. '<p><strong>Carry-over balance:</strong> $' . number_format($carry, 2) . '<br>'
. '<strong>Registration fee:</strong> $' . number_format($registrationFee, 2) . '<br>'
. '<strong>New-year tuition due now:</strong> $' . number_format($tuition, 2) . '<br>'
. '<strong>Mandatory fees:</strong> $' . number_format($mandatory, 2) . '<br>'
. '<strong>Total currently due:</strong> $' . number_format($total, 2) . '</p>'
. '<p>' . esc($message) . '</p>';
}
private function recipientFamilies(array $schoolYear, ?string $testEmail): array
{
$builder = $this->db->table('users u')
->select('u.id AS parent_user_id, u.firstname, u.lastname, u.email')
->where('u.email IS NOT NULL')
->where('u.email !=', '');
if ($this->db->fieldExists('user_type', 'users')) {
$builder->where('u.user_type', 'primary');
}
if ($testEmail !== null) {
$builder->limit(1);
}
$families = [];
foreach ($builder->get()->getResultArray() as $row) {
$parentId = (int) ($row['parent_user_id'] ?? 0);
$students = $this->studentsForParent($parentId);
if ($students === []) {
continue;
}
$email = $testEmail ?? strtolower(trim((string) ($row['email'] ?? '')));
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
continue;
}
$name = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')) ?: 'Parent or Guardian';
$families[$parentId] = [
'parent_user_id' => $parentId,
'family_account_id' => $parentId,
'name' => $name,
'recipients' => [$email],
'students' => $students,
];
}
return array_values($families);
}
private function studentsForParent(int $parentId): array
{
return $this->db->table('students')
->select('id, firstname, lastname, dob')
->where('parent_id', $parentId)
->orderBy('lastname', 'ASC')
->orderBy('firstname', 'ASC')
->get()
->getResultArray();
}
private function recordGenerated(array $schoolYear, array $family, array $message): int
{
$now = date('Y-m-d H:i:s');
$this->db->table('enrollment_email_records')->insert([
'school_year' => (string) ($schoolYear['name'] ?? ''),
'school_year_id' => (int) ($schoolYear['id'] ?? 0) ?: null,
'family_account_id' => (int) ($family['family_account_id'] ?? 0) ?: null,
'parent_user_id' => (int) ($family['parent_user_id'] ?? 0) ?: null,
'recipient_addresses_json' => json_encode($message['recipients']),
'template_version' => self::TEMPLATE_VERSION,
'generated_subject' => $message['subject'],
'generated_body' => $message['body'],
'student_ids_included_json' => json_encode($message['student_ids']),
'generated_at' => $now,
'delivery_status' => 'generated',
'created_at' => $now,
'updated_at' => $now,
]);
return (int) $this->db->insertID();
}
private function recordDelivery(int $recordId, bool $sent, ?string $failure): void
{
$this->db->table('enrollment_email_records')
->where('id', $recordId)
->update([
'delivery_status' => $sent ? 'sent' : 'failed',
'sent_at' => $sent ? date('Y-m-d H:i:s') : null,
'failure_reason' => $failure,
'retry_count' => $sent ? 0 : 1,
'updated_at' => date('Y-m-d H:i:s'),
]);
}
private function alreadySent(string $schoolYear, int $parentId): bool
{
if (! $this->db->tableExists('enrollment_email_records')) {
return false;
}
return $this->db->table('enrollment_email_records')
->where('school_year', $schoolYear)
->where('parent_user_id', $parentId)
->where('delivery_status', 'sent')
->countAllResults() > 0;
}
private function latestEmailRecord(string $schoolYear, int $parentId): ?array
{
if (! $this->db->tableExists('enrollment_email_records')) {
return null;
}
return $this->db->table('enrollment_email_records')
->select('delivery_status, sent_at, failure_reason')
->where('school_year', $schoolYear)
->where('parent_user_id', $parentId)
->orderBy('created_at', 'DESC')
->orderBy('id', 'DESC')
->limit(1)
->get()
->getRowArray() ?: null;
}
private function registrationYearsForDate(DateTimeInterface $date, bool $force): array
{
$builder = $this->db->table('school_years')->where('registration_starts_on IS NOT NULL');
if (! $force) {
$builder->where('registration_starts_on', $date->format('Y-m-d'));
}
return $builder->orderBy('registration_starts_on', 'ASC')->get()->getResultArray();
}
private function schoolYearByName(string $schoolYear): ?array
{
return $this->db->table('school_years')->where('name', $schoolYear)->limit(1)->get()->getRowArray() ?: null;
}
private function invoiceBalance(int $parentId, string $schoolYear): float
{
if (! $this->db->tableExists('invoices')) {
return 0.0;
}
$row = $this->db->table('invoices')
->select('COALESCE(SUM(balance), 0) AS balance', false)
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->get()
->getRowArray();
return round((float) ($row['balance'] ?? 0), 2);
}
private function registrationStatus(array $evaluation): string
{
if ((string) ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::PASSED) {
return 'Eligible';
}
if (($evaluation['blockers'] ?? []) !== []) {
return ($evaluation['adult_student'] ?? false) ? 'Student Action Required' : 'Not Eligible';
}
return ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM ? 'Eligible with pending placement' : 'Eligible';
}
private function placementText(array $evaluation): string
{
return match ((string) ($evaluation['placement_status'] ?? '')) {
'automatic_distribution_pending' => $this->assignedGradeText($evaluation),
'same_class_assigned' => 'Same grade and class',
'temporary_same_grade' => 'Same grade initially',
'manual_class_required' => 'Same grade - administrative class assignment required',
'exit_required' => 'Completion or exit process required',
default => 'Pending',
};
}
private function requiredAction(array $evaluation, string $deadline): string
{
if ((string) ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::PASSED) {
return 'Complete re-enrollment before ' . $deadline . '.';
}
if (($evaluation['blockers'] ?? []) !== []) {
return ($evaluation['adult_student'] ?? false) ? 'Student must complete the authorized adult-student process or contact administration.' : 'Contact the school administration.';
}
return ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM
? 'Complete re-enrollment and follow the school instructions regarding the make-up exam.'
: 'Complete re-enrollment before ' . $deadline . '.';
}
private function previousSchoolYearName(string $schoolYear): ?string
{
return preg_match('/^(\d{4})-(\d{4})$/', $schoolYear, $m) ? ((int) $m[1] - 1) . '-' . ((int) $m[2] - 1) : null;
}
private function dateText(mixed $value): string
{
$value = trim((string) $value);
if ($value === '') {
return 'the posted date';
}
try {
return (new \DateTimeImmutable($value))->format('F j, Y');
} catch (\Throwable) {
return $value;
}
}
private function currentGradeText(array $evaluation): string
{
return $this->gradeText(
$evaluation['source_grade_name'] ?? $evaluation['source_class_section_name'] ?? null,
'the current grade'
);
}
private function assignedGradeText(array $evaluation): string
{
return $this->gradeText(
$evaluation['assigned_grade_name'] ?? null,
'Grade to be assigned'
);
}
private function gradeText(mixed $value, string $fallback): string
{
$grade = trim((string) $value);
if ($grade === '') {
return $fallback;
}
return preg_match('/^grade\b/i', $grade) === 1 ? $grade : 'Grade ' . $grade;
}
}
@@ -0,0 +1,666 @@
<?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';
}
}
+13 -6
View File
@@ -7,6 +7,7 @@ use App\Models\SchoolYearClosingItemModel;
use App\Models\SchoolYearModel;
use App\Models\ConfigurationModel;
use App\Models\InvoiceModel;
use App\Support\Enrollment\DeliberationDecision;
use App\Support\SchoolYear\SchoolYearStatus;
use CodeIgniter\Database\BaseConnection;
use InvalidArgumentException;
@@ -644,6 +645,7 @@ final class SchoolYearClosingService
'auto_kg_pass' => false,
'year_score' => null,
'decision' => '',
'normalized_decision' => null,
'source' => 'missing',
'notes' => '',
'status' => 'missing',
@@ -667,6 +669,7 @@ final class SchoolYearClosingService
if ($decision !== null) {
$student['year_score'] = $decision['year_score'];
$student['decision'] = $decision['decision'];
$student['normalized_decision'] = $decision['normalized_decision'];
$student['source'] = $decision['source'];
$student['notes'] = $decision['notes'];
$student['status'] = $decision['status'];
@@ -676,17 +679,19 @@ final class SchoolYearClosingService
}
if ($decision === null && $this->isKgStudent($student)) {
$kgAgeStatus = $this->kgAgeStatusByTargetYearCutoff((string) $student['dob'], $targetSchoolYear);
$kgAgeStatus = $this->kgAgeStatusByTargetYearStartCutoff((string) $student['dob'], $targetSchoolYear);
if ($kgAgeStatus === 'pass') {
$student['decision'] = 'Pass';
$student['normalized_decision'] = DeliberationDecision::PASSED;
$student['source'] = 'automatic_kg_age';
$student['notes'] = 'Auto-pass KG student: age 6 or older by Dec 31 of the next school year start year.';
$student['notes'] = 'Auto-pass KG student: age 6 or older by Sep 1 of the next school year.';
$student['status'] = 'decided';
$student['auto_kg_pass'] = true;
} elseif ($kgAgeStatus === 'keep_kg') {
$student['decision'] = 'Keep KG';
$student['normalized_decision'] = DeliberationDecision::REPEAT_CLASS;
$student['source'] = 'automatic_kg_age';
$student['notes'] = 'Auto-keep KG student: younger than 6 by Dec 31 of the next school year start year.';
$student['notes'] = 'Auto-keep KG student: younger than 6 by Sep 1 of the next school year.';
$student['status'] = 'decided';
}
}
@@ -705,7 +710,7 @@ final class SchoolYearClosingService
$summary['pending_decision']++;
} else {
$summary['with_decision']++;
if (strcasecmp((string) $student['decision'], 'Pass') === 0) {
if (($student['normalized_decision'] ?? null) === DeliberationDecision::PASSED) {
$summary['pass']++;
if ($queue === null && $student['auto_kg_pass'] !== true) {
$summary['missing_queue']++;
@@ -745,7 +750,7 @@ final class SchoolYearClosingService
return false;
}
private function kgAgeStatusByTargetYearCutoff(string $dob, ?string $targetSchoolYear): string
private function kgAgeStatusByTargetYearStartCutoff(string $dob, ?string $targetSchoolYear): string
{
$dob = trim($dob);
if ($dob === '' || $targetSchoolYear === null || ! preg_match('/^(\d{4})-\d{4}$/', $targetSchoolYear, $matches)) {
@@ -754,7 +759,7 @@ final class SchoolYearClosingService
try {
$birthDate = new \DateTimeImmutable($dob);
$cutoff = new \DateTimeImmutable($matches[1] . '-12-31');
$cutoff = new \DateTimeImmutable($matches[1] . '-09-01');
} catch (\Throwable) {
return '';
}
@@ -808,11 +813,13 @@ final class SchoolYearClosingService
$decision = trim((string) ($row['decision'] ?? ''));
$source = trim((string) ($row['source'] ?? ''));
$status = $decision === '' || $source === 'pending' ? 'pending' : 'decided';
$normalizedDecision = DeliberationDecision::normalize($decision);
$decisions[$studentId] = [
'class_section_name' => (string) ($row['class_section_name'] ?? ''),
'year_score' => is_numeric($row['year_score'] ?? null) ? round((float) $row['year_score'], 2) : null,
'decision' => $decision,
'normalized_decision' => $normalizedDecision,
'source' => $source !== '' ? $source : ($status === 'pending' ? 'pending' : 'manual'),
'notes' => (string) ($row['notes'] ?? ''),
'status' => $status,
+59 -1
View File
@@ -22,7 +22,7 @@ final class SchoolYearContextService
IncomingRequest $request,
?int $routeSchoolYearId = null
): SchoolYearContext {
$requestedId = $routeSchoolYearId ?? $this->normalizeInt($request->getGet('school_year_id'));
$requestedId = $routeSchoolYearId ?? $this->requestedSchoolYearId($request);
$requestedName = $this->legacyYearName($request);
$userId = (int) (session()->get('user_id') ?? 0);
@@ -118,6 +118,27 @@ final class SchoolYearContextService
return $context;
}
public function forYearName(string $yearName): SchoolYearContext
{
$yearName = trim($yearName);
if ($yearName === '') {
throw new SchoolYearNotFoundException('Selected school year was not found.');
}
$row = $this->schoolYearModel
->where('name', $yearName)
->first();
if ($row === null) {
throw new SchoolYearNotFoundException('Selected school year was not found.');
}
$userId = (int) (session()->get('user_id') ?? 0);
return $this->authorizedContext($row, $userId, true);
}
public function clearSelection(): void
{
session()->remove('selected_school_year_id');
@@ -155,6 +176,31 @@ final class SchoolYearContextService
return (int) $value;
}
private function requestedSchoolYearId(IncomingRequest $request): ?int
{
$ids = [];
foreach (['school_year_id', 'schoolYearId', 'year_id'] as $key) {
$value = $this->normalizeInt($request->getGet($key));
if ($value !== null) {
$ids[$key] = $value;
}
if ($this->isWriteRequest($request)) {
$value = $this->normalizeInt($request->getPost($key));
if ($value !== null) {
$ids['post:' . $key] = $value;
}
}
}
if (count(array_unique($ids)) > 1) {
throw new InvalidSchoolYearSelectionException('Conflicting school-year parameters were provided.');
}
return $ids === [] ? null : (int) reset($ids);
}
private function legacyYearName(IncomingRequest $request): string
{
$names = [];
@@ -164,6 +210,13 @@ final class SchoolYearContextService
if ($value !== '') {
$names[$key] = $value;
}
if ($this->isWriteRequest($request)) {
$value = trim((string) ($request->getPost($key) ?? ''));
if ($value !== '') {
$names['post:' . $key] = $value;
}
}
}
if (count(array_unique($names)) > 1) {
@@ -179,6 +232,11 @@ final class SchoolYearContextService
return $names === [] ? '' : (string) reset($names);
}
private function isWriteRequest(IncomingRequest $request): bool
{
return in_array(strtoupper($request->getMethod()), ['POST', 'PUT', 'PATCH', 'DELETE'], true);
}
private function authorizedContext(array $row, int $userId, bool $explicit): SchoolYearContext
{
if (! $this->isSelectable($row, $userId)) {