Files
alrahma_sunday_school_api/app/Services/Promotions/Placement/PlacementPoolBuilder.php
T
2026-06-11 11:46:12 -04:00

189 lines
7.7 KiB
PHP

<?php
namespace App\Services\Promotions\Placement;
use App\Models\StudentPromotionRecord;
use Illuminate\Support\Facades\DB;
class PlacementPoolBuilder
{
public function __construct(private ScoreBandClassifier $bands) {}
public function build(string $fromSchoolYear, string $toSchoolYear, ?int $toGradeLevelId = null): array
{
$pool = [];
$exceptions = $this->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);
}
}