189 lines
7.7 KiB
PHP
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);
|
|
}
|
|
}
|