667 lines
28 KiB
PHP
667 lines
28 KiB
PHP
<?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';
|
|
}
|
|
}
|