exceptionBuckets($fromSchoolYear, $toSchoolYear); $returning = StudentPromotionRecord::query() ->from('student_promotion_records as r') ->join('students as s', 's.id', '=', 'r.student_id') ->leftJoin('student_decisions as d', function ($join) use ($fromSchoolYear) { $join->on('d.student_id', '=', 'r.student_id') ->where('d.school_year', '=', $fromSchoolYear); }) ->where('r.current_school_year', $fromSchoolYear) ->where('r.next_school_year', $toSchoolYear) ->where('r.enrollment_status', StudentPromotionRecord::ENROLLMENT_COMPLETED) ->where('r.promotion_status', StudentPromotionRecord::STATUS_PROMOTED_AND_ENROLLED) ->when($toGradeLevelId !== null, fn ($q) => $q->where('r.promoted_level_id', $toGradeLevelId)) ->selectRaw('r.*, s.firstname, s.lastname, s.is_active, d.id as decision_id, d.decision, d.year_score') ->orderBy('s.lastname') ->orderBy('s.firstname') ->orderBy('s.id') ->get(); foreach ($returning as $row) { $studentId = (int) $row->student_id; if ((int) ($row->is_active ?? 1) === 0) { $exceptions['withdrawn_or_inactive_student'][] = $studentId; continue; } if ($this->alreadyPlaced($studentId, $toSchoolYear)) { $exceptions['duplicate_target_year_placement'][] = $studentId; continue; } if (! $this->isPromotedDecision((string) ($row->decision ?? ''))) { $bucket = $row->decision === null ? 'missing_student_decision' : 'decision_conflicts_with_enrollment_request'; $exceptions[$bucket][] = $studentId; continue; } $score = $row->year_score !== null ? (float) $row->year_score : ($row->final_average !== null ? (float) $row->final_average : null); if ($score === null) { $exceptions['missing_student_decision'][] = $studentId; continue; } try { $band = $this->bands->fromScore($score, true); } catch (\RuntimeException) { $exceptions['failed_retained'][] = $studentId; continue; } $pool[] = [ 'student_id' => $studentId, 'student_type' => 'returning', 'student_name' => trim((string) $row->lastname.', '.(string) $row->firstname), 'source_decision_id' => $row->decision_id ? (int) $row->decision_id : null, 'source_enrollment_id' => $row->enrollment_id ? (int) $row->enrollment_id : null, 'final_score' => $score, 'placement_score' => null, 'score_band' => $band, 'placement_band_reason' => 'returning_student_final_score', ]; } $newStudents = DB::table('enrollments as e') ->join('students as s', 's.id', '=', 'e.student_id') ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'e.class_section_id') ->where('e.school_year', $toSchoolYear) ->where('s.is_new', 1) ->where('e.admission_status', 'accepted') ->whereIn('e.enrollment_status', ['enrolled', 'payment pending']) ->when($toGradeLevelId !== null, function ($q) use ($toGradeLevelId) { $q->where(function ($inner) use ($toGradeLevelId) { $inner->whereNull('e.class_section_id') ->orWhere('cs.class_id', $toGradeLevelId); }); }) ->selectRaw('e.id as enrollment_id, e.student_id, s.firstname, s.lastname, s.is_active') ->orderBy('s.lastname') ->orderBy('s.firstname') ->orderBy('s.id') ->get(); foreach ($newStudents as $row) { $studentId = (int) $row->student_id; if ((int) ($row->is_active ?? 1) === 0) { $exceptions['withdrawn_or_inactive_student'][] = $studentId; continue; } if ($this->alreadyPlaced($studentId, $toSchoolYear)) { $exceptions['duplicate_target_year_placement'][] = $studentId; continue; } $pool[] = [ 'student_id' => $studentId, 'student_type' => 'new', 'student_name' => trim((string) $row->lastname.', '.(string) $row->firstname), 'source_decision_id' => null, 'source_enrollment_id' => (int) $row->enrollment_id, 'final_score' => null, 'placement_score' => null, 'score_band' => 'D', 'placement_band_reason' => 'new_student_default_band', ]; } return [ 'pool' => $pool, 'exceptions' => array_map(fn ($ids) => array_values(array_unique($ids)), $exceptions), ]; } private function exceptionBuckets(string $fromSchoolYear, string $toSchoolYear): array { $buckets = [ 'passed_but_parent_enrollment_missing' => [], 'passed_and_parent_enrollment_completed' => [], 'failed_retained' => [], 'missing_student_decision' => [], 'pending_student_decision' => [], 'decision_conflicts_with_enrollment_request' => [], 'withdrawn_or_inactive_student' => [], 'duplicate_target_year_placement' => [], ]; $rows = DB::table('student_decisions as d') ->leftJoin('student_promotion_records as r', function ($join) use ($toSchoolYear) { $join->on('r.student_id', '=', 'd.student_id') ->where('r.next_school_year', '=', $toSchoolYear); }) ->where('d.school_year', $fromSchoolYear) ->select('d.student_id', 'd.decision', 'r.enrollment_status') ->get(); foreach ($rows as $row) { $decision = strtolower(trim((string) $row->decision)); $studentId = (int) $row->student_id; if ($this->isPromotedDecision($decision)) { if ($row->enrollment_status === StudentPromotionRecord::ENROLLMENT_COMPLETED) { $buckets['passed_and_parent_enrollment_completed'][] = $studentId; } else { $buckets['passed_but_parent_enrollment_missing'][] = $studentId; } } elseif (in_array($decision, ['failed', 'fail', 'retained', 'repeat', 'repeated_level'], true)) { $buckets['failed_retained'][] = $studentId; } elseif ($decision === '' || $decision === 'pending') { $buckets['pending_student_decision'][] = $studentId; } } return $buckets; } private function alreadyPlaced(int $studentId, string $schoolYear): bool { return DB::table('student_class') ->where('student_id', $studentId) ->where('school_year', $schoolYear) ->where('is_event_only', 0) ->exists(); } private function isPromotedDecision(string $decision): bool { $normalized = strtolower(trim($decision)); return in_array($normalized, ['passed', 'pass', 'promoted', 'promote', 'eligible_to_continue'], true); } }