1758 lines
71 KiB
PHP
1758 lines
71 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
|
||
{
|
||
private const DECISION_ELIGIBLE = 'ELIGIBLE';
|
||
private const DECISION_ELIGIBLE_WITH_WARNING = 'ELIGIBLE_WITH_WARNING';
|
||
private const DECISION_INELIGIBLE = 'INELIGIBLE';
|
||
private const DECISION_REVIEW_REQUIRED = 'REVIEW_REQUIRED';
|
||
private const DECISION_EXCEPTION_ELIGIBLE = 'EXCEPTION_ELIGIBLE';
|
||
private const DECISION_ALREADY_ENROLLED = 'ALREADY_ENROLLED';
|
||
|
||
private const NON_OVERRIDABLE_RULE_CODES = [
|
||
'STUDENT_NOT_LINKED',
|
||
'SOURCE_YEAR_NOT_FOUND',
|
||
'TARGET_YEAR_NOT_FOUND',
|
||
'ALREADY_ENROLLED',
|
||
];
|
||
|
||
public function __construct(private readonly BaseConnection $db)
|
||
{
|
||
}
|
||
|
||
public function evaluateForParent(
|
||
int $parentId,
|
||
int $studentId,
|
||
string $sourceSchoolYear,
|
||
string $targetSchoolYear,
|
||
string $actorRole = 'parent',
|
||
?DateTimeInterface $now = null
|
||
): array {
|
||
$evaluation = $this->evaluate($studentId, $sourceSchoolYear, $targetSchoolYear, $actorRole, $now);
|
||
$this->ensureDecisionFields($evaluation);
|
||
|
||
$student = $this->student($studentId);
|
||
if ($parentId <= 0 || $student === null || (int) ($student['parent_id'] ?? 0) !== $parentId) {
|
||
$this->addBlocker($evaluation, 'STUDENT_NOT_LINKED', 'This student is not linked to the signed-in parent account.');
|
||
}
|
||
|
||
if ($this->schoolYearByName($targetSchoolYear) === null) {
|
||
$this->addBlocker($evaluation, 'TARGET_YEAR_NOT_FOUND', 'Target school year configuration was not found.');
|
||
}
|
||
|
||
if ($sourceSchoolYear === '' || $this->sourceAssignment($studentId, $sourceSchoolYear) === null) {
|
||
$this->addBlocker($evaluation, 'SOURCE_YEAR_NOT_FOUND', 'Student does not belong to the closing school year.');
|
||
}
|
||
|
||
$existing = $this->latestEnrollment($studentId, $targetSchoolYear);
|
||
if ($this->activeEnrollmentBlocksDuplicate($existing)) {
|
||
$this->addBlocker($evaluation, 'ALREADY_ENROLLED', 'Student already has an active enrollment for the selected school year.');
|
||
} elseif ($this->deniedOrWithdrawnEnrollmentBlocksStandardEligibility($existing)) {
|
||
$code = strtolower((string) ($existing['admission_status'] ?? '')) === 'denied'
|
||
|| strtolower((string) ($existing['enrollment_status'] ?? '')) === 'denied'
|
||
? 'DENIED'
|
||
: 'WITHDRAWN';
|
||
$this->addBlocker($evaluation, $code, 'Student has a target-year enrollment status that requires administration review.');
|
||
}
|
||
|
||
$this->applyHouseholdLastNameRule($evaluation, $parentId);
|
||
$this->applyFinancialRule($evaluation, $parentId, $sourceSchoolYear, $targetSchoolYear);
|
||
$this->applyCarriedForwardLastNameException($evaluation, $parentId, $studentId, $sourceSchoolYear, $targetSchoolYear);
|
||
$this->deriveAcademicRuleCodes($evaluation);
|
||
$this->applyScopedException($evaluation, $parentId, $studentId, $sourceSchoolYear, $targetSchoolYear);
|
||
$this->finalizeParentDecision($evaluation);
|
||
$this->appendFlagsFromRuleCodes($evaluation);
|
||
|
||
return $evaluation;
|
||
}
|
||
|
||
public function markExceptionUsed(int $exceptionId, int $enrollmentId): void
|
||
{
|
||
if ($exceptionId <= 0 || $enrollmentId <= 0 || ! $this->db->tableExists('enrollment_exceptions')) {
|
||
return;
|
||
}
|
||
|
||
$this->db->table('enrollment_exceptions')
|
||
->where('id', $exceptionId)
|
||
->where('status', 'active')
|
||
->update([
|
||
'status' => 'used',
|
||
'used_at' => date('Y-m-d H:i:s'),
|
||
'enrollment_id' => $enrollmentId,
|
||
'updated_at' => date('Y-m-d H:i:s'),
|
||
]);
|
||
}
|
||
|
||
public function auditEnrollmentDecision(
|
||
int $studentId,
|
||
string $targetSchoolYear,
|
||
?string $sourceSchoolYear,
|
||
string $action,
|
||
?int $performedBy,
|
||
?array $original,
|
||
array $new,
|
||
string $reason
|
||
): void {
|
||
if (! $this->db->tableExists('enrollment_transition_audits')) {
|
||
return;
|
||
}
|
||
|
||
$this->db->table('enrollment_transition_audits')->insert([
|
||
'student_id' => $studentId,
|
||
'school_year' => $targetSchoolYear,
|
||
'source_school_year' => $sourceSchoolYear,
|
||
'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' => $reason,
|
||
'created_at' => date('Y-m-d H:i:s'),
|
||
]);
|
||
}
|
||
|
||
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) {
|
||
$payload['id'] = (int) $original['id'];
|
||
} else {
|
||
$payload['created_at'] = date('Y-m-d H:i:s');
|
||
}
|
||
|
||
(new EnrollmentStatusService($this->db))->upsertStatus(
|
||
$payload,
|
||
$performedBy,
|
||
'initial_transition_applied'
|
||
);
|
||
|
||
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';
|
||
if ($actorRole !== 'admin') {
|
||
$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);
|
||
}
|
||
}
|
||
|
||
public function syncDashboardBlockageFlags(string $targetSchoolYear, ?int $performedBy = null): int
|
||
{
|
||
if (! $this->db->tableExists('enrollment_flags')) {
|
||
return 0;
|
||
}
|
||
|
||
$sourceSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
|
||
if ($sourceSchoolYear === null) {
|
||
return 0;
|
||
}
|
||
|
||
$students = $this->sourceYearStudents($sourceSchoolYear);
|
||
if ($students === []) {
|
||
return 0;
|
||
}
|
||
|
||
$decisions = $this->latestDecisionsByStudent($sourceSchoolYear);
|
||
$enrolledIds = $this->activeTargetEnrollmentStudentIds($targetSchoolYear);
|
||
$bypassCodesByStudent = $this->activeBypassCodesByStudent($targetSchoolYear);
|
||
$written = 0;
|
||
|
||
foreach ($students as $student) {
|
||
$studentId = (int) ($student['student_id'] ?? 0);
|
||
if ($studentId <= 0) {
|
||
continue;
|
||
}
|
||
|
||
$decisionRow = $decisions[$studentId] ?? null;
|
||
$decision = DeliberationDecision::normalize($decisionRow['deliberation_decision_standard'] ?? null)
|
||
?? DeliberationDecision::normalize($decisionRow['decision'] ?? null);
|
||
$alreadyEnrolled = isset($enrolledIds[$studentId]);
|
||
$flags = [];
|
||
|
||
if ($decisionRow === null || $decision === null) {
|
||
if (! $alreadyEnrolled) {
|
||
$flags[] = $this->flag('DEFERRED_DELIBERATION', 'high', [
|
||
'reason' => 'Missing or unrecognized final deliberation decision.',
|
||
]);
|
||
}
|
||
} elseif ($decision === DeliberationDecision::EXPELLED && ! $alreadyEnrolled) {
|
||
$flags[] = $this->flag('RESTRICTED_ADMINISTRATIVE_REVIEW', 'high');
|
||
} elseif ($decision === DeliberationDecision::WITHDRAWN && ! $alreadyEnrolled) {
|
||
$flags[] = $this->flag('WITHDRAWAL_REVIEW_REQUIRED', 'normal');
|
||
} elseif ($decision === DeliberationDecision::DEFERRED_DECISION && ! $alreadyEnrolled) {
|
||
$flags[] = $this->flag('DEFERRED_DELIBERATION', 'high');
|
||
} elseif ($decision === DeliberationDecision::MAKE_UP_EXAM) {
|
||
$flags[] = $this->flag('PENDING_MAKE_UP_EXAM_PROMOTION', 'high', [
|
||
'current_class_section_name' => $student['class_section_name'] ?? ($decisionRow['class_section_name'] ?? null),
|
||
]);
|
||
}
|
||
|
||
$age = EnrollmentEligibility::ageOnSeptemberFirst($student['dob'] ?? null, $targetSchoolYear);
|
||
if ($age !== null && $age >= 18 && ! $alreadyEnrolled) {
|
||
$flags[] = $this->flag('ADULT_STUDENT_ACTION_REQUIRED', 'normal', [
|
||
'age_on_reference_date' => $age,
|
||
]);
|
||
}
|
||
|
||
foreach ($flags as $flag) {
|
||
if ($this->flagCoveredByException((string) ($flag['flag_type'] ?? ''), $bypassCodesByStudent[$studentId] ?? [])) {
|
||
continue;
|
||
}
|
||
if ($this->upsertOpenFlag($studentId, $targetSchoolYear, $sourceSchoolYear, $flag, $performedBy)) {
|
||
$written++;
|
||
}
|
||
}
|
||
}
|
||
|
||
$written += $this->syncSiblingLastNameFlags($students, $enrolledIds, $bypassCodesByStudent, $targetSchoolYear, $sourceSchoolYear, $performedBy);
|
||
$written += $this->syncFinancialFlags($students, $enrolledIds, $bypassCodesByStudent, $targetSchoolYear, $sourceSchoolYear, $performedBy);
|
||
|
||
return $written;
|
||
}
|
||
|
||
private function writeFlags(array $evaluation, ?int $performedBy): void
|
||
{
|
||
foreach ($evaluation['flags'] ?? [] as $flag) {
|
||
$this->upsertOpenFlag(
|
||
(int) $evaluation['student_id'],
|
||
(string) $evaluation['target_school_year'],
|
||
(string) $evaluation['source_school_year'],
|
||
$flag,
|
||
$performedBy
|
||
);
|
||
}
|
||
}
|
||
|
||
private function upsertOpenFlag(
|
||
int $studentId,
|
||
string $schoolYear,
|
||
string $sourceSchoolYear,
|
||
array $flag,
|
||
?int $performedBy
|
||
): bool {
|
||
if ($studentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('enrollment_flags')) {
|
||
return false;
|
||
}
|
||
|
||
$flagType = (string) ($flag['flag_type'] ?? '');
|
||
if ($flagType === '') {
|
||
return false;
|
||
}
|
||
|
||
$existing = $this->db->table('enrollment_flags')
|
||
->where('student_id', $studentId)
|
||
->where('school_year', $schoolYear)
|
||
->where('flag_type', $flagType)
|
||
->where('status', 'open')
|
||
->limit(1)
|
||
->get()
|
||
->getRowArray();
|
||
|
||
if ($existing !== null) {
|
||
return false;
|
||
}
|
||
|
||
$this->db->table('enrollment_flags')->insert([
|
||
'flag_type' => $flagType,
|
||
'student_id' => $studentId,
|
||
'school_year' => $schoolYear,
|
||
'source_school_year' => $sourceSchoolYear !== '' ? $sourceSchoolYear : null,
|
||
'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'),
|
||
]);
|
||
|
||
return true;
|
||
}
|
||
|
||
private function activeBypassCodesByStudent(string $schoolYear): array
|
||
{
|
||
if ($schoolYear === '' || ! $this->db->tableExists('enrollment_exceptions')) {
|
||
return [];
|
||
}
|
||
|
||
$now = date('Y-m-d H:i:s');
|
||
$rows = $this->db->table('enrollment_exceptions')
|
||
->select('student_id, bypassed_rule_codes_json, reason_code')
|
||
->where('school_year', $schoolYear)
|
||
->where('status', 'active')
|
||
->groupStart()
|
||
->where('starts_at IS NULL', null, false)
|
||
->orWhere('starts_at <=', $now)
|
||
->groupEnd()
|
||
->groupStart()
|
||
->where('expires_at IS NULL', null, false)
|
||
->orWhere('expires_at >=', $now)
|
||
->groupEnd()
|
||
->get()
|
||
->getResultArray();
|
||
|
||
$byStudent = [];
|
||
foreach ($rows as $row) {
|
||
$studentId = (int) ($row['student_id'] ?? 0);
|
||
if ($studentId <= 0) {
|
||
continue;
|
||
}
|
||
$codes = json_decode((string) ($row['bypassed_rule_codes_json'] ?? ''), true);
|
||
$codes = is_array($codes) ? array_values(array_filter(array_map('strval', $codes))) : [];
|
||
$reason = strtoupper(trim((string) ($row['reason_code'] ?? '')));
|
||
if ($reason !== '') {
|
||
$codes[] = $reason;
|
||
}
|
||
$byStudent[$studentId] = array_values(array_unique(array_merge($byStudent[$studentId] ?? [], $codes)));
|
||
}
|
||
|
||
return $byStudent;
|
||
}
|
||
|
||
private function flagCoveredByException(string $flagType, array $bypassCodes): bool
|
||
{
|
||
if ($flagType === '' || $bypassCodes === []) {
|
||
return false;
|
||
}
|
||
|
||
$bypassCodes = array_map(static fn ($code): string => strtoupper(trim((string) $code)), $bypassCodes);
|
||
$mapped = match ($flagType) {
|
||
'AGE_EXCEPTION_REQUIRED' => ['AGE_RULE_BLOCKED', 'AGE_EXCEPTION_REQUIRED'],
|
||
'LATE_REGISTRATION_EXCEPTION' => ['REGISTRATION_CLOSED', 'LATE_REGISTRATION_EXCEPTION'],
|
||
'FINANCIAL_REVIEW_REQUIRED' => ['OUTSTANDING_BALANCE_BLOCKED', 'FINANCE_APPROVAL_REQUIRED', 'FINANCIAL_REVIEW_REQUIRED'],
|
||
'CLASS_CAPACITY_EXCEPTION_REQUIRED' => ['CLASS_CAPACITY_EXCEPTION_REQUIRED'],
|
||
'RESTRICTED_ADMINISTRATIVE_REVIEW' => ['EXPELLED', 'DENIED', 'RESTRICTED_ADMINISTRATIVE_REVIEW'],
|
||
'WITHDRAWAL_REVIEW_REQUIRED' => ['WITHDRAWN', 'WITHDRAWAL_REVIEW_REQUIRED'],
|
||
'DEFERRED_DELIBERATION' => ['NO_FINAL_DECISION', 'UNRECOGNIZED_DECISION', 'DEFERRED_DECISION', 'DEFERRED_DELIBERATION'],
|
||
'SIBLING_LAST_NAME_MISMATCH' => ['SIBLING_LAST_NAME_MISMATCH', 'SIBLING_LAST_NAME_REVIEWED'],
|
||
'ADULT_STUDENT_ACTION_REQUIRED' => ['ADULT_STUDENT_PARENT_BLOCKED', 'ADULT_STUDENT_ACTION_REQUIRED'],
|
||
default => [$flagType],
|
||
};
|
||
|
||
return array_intersect($mapped, $bypassCodes) !== [];
|
||
}
|
||
|
||
private function previousSchoolYearName(string $schoolYear): ?string
|
||
{
|
||
return preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches)
|
||
? ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1)
|
||
: null;
|
||
}
|
||
|
||
private function sourceYearStudents(string $sourceSchoolYear): array
|
||
{
|
||
$byId = [];
|
||
|
||
if ($this->db->tableExists('student_class')) {
|
||
$rows = $this->db->table('student_class sc')
|
||
->select('sc.student_id, s.parent_id, s.firstname, s.lastname, s.dob, cs.class_section_name')
|
||
->join('students s', 's.id = sc.student_id', 'left')
|
||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left')
|
||
->where('sc.school_year', $sourceSchoolYear)
|
||
->get()
|
||
->getResultArray();
|
||
foreach ($rows as $row) {
|
||
$studentId = (int) ($row['student_id'] ?? 0);
|
||
if ($studentId > 0) {
|
||
$byId[$studentId] = $row;
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($this->db->tableExists('enrollments')) {
|
||
$rows = $this->db->table('enrollments e')
|
||
->select('e.student_id, s.parent_id, s.firstname, s.lastname, s.dob, cs.class_section_name')
|
||
->join('students s', 's.id = e.student_id', 'left')
|
||
->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left')
|
||
->where('e.school_year', $sourceSchoolYear)
|
||
->get()
|
||
->getResultArray();
|
||
foreach ($rows as $row) {
|
||
$studentId = (int) ($row['student_id'] ?? 0);
|
||
if ($studentId > 0 && ! isset($byId[$studentId])) {
|
||
$byId[$studentId] = $row;
|
||
}
|
||
}
|
||
}
|
||
|
||
return array_values($byId);
|
||
}
|
||
|
||
private function latestDecisionsByStudent(string $sourceSchoolYear): array
|
||
{
|
||
if (! $this->db->tableExists('student_decisions')) {
|
||
return [];
|
||
}
|
||
|
||
$select = ['student_id', 'decision', 'source', 'notes', 'class_section_name', 'updated_at', 'id'];
|
||
if ($this->db->fieldExists('deliberation_decision_standard', 'student_decisions')) {
|
||
$select[] = 'deliberation_decision_standard';
|
||
}
|
||
|
||
$rows = $this->db->table('student_decisions')
|
||
->select($select)
|
||
->where('school_year', $sourceSchoolYear)
|
||
->orderBy('updated_at', 'DESC')
|
||
->orderBy('id', 'DESC')
|
||
->get()
|
||
->getResultArray();
|
||
|
||
$latest = [];
|
||
foreach ($rows as $row) {
|
||
$studentId = (int) ($row['student_id'] ?? 0);
|
||
if ($studentId > 0 && ! isset($latest[$studentId])) {
|
||
$latest[$studentId] = $row;
|
||
}
|
||
}
|
||
|
||
return $latest;
|
||
}
|
||
|
||
private function activeTargetEnrollmentStudentIds(string $targetSchoolYear): array
|
||
{
|
||
if (! $this->db->tableExists('enrollments')) {
|
||
return [];
|
||
}
|
||
|
||
$select = ['student_id', 'enrollment_status', 'admission_status'];
|
||
if ($this->db->fieldExists('is_withdrawn', 'enrollments')) {
|
||
$select[] = 'is_withdrawn';
|
||
}
|
||
|
||
$rows = $this->db->table('enrollments')
|
||
->select(implode(', ', $select))
|
||
->where('school_year', $targetSchoolYear)
|
||
->orderBy('updated_at', 'DESC')
|
||
->orderBy('id', 'DESC')
|
||
->get()
|
||
->getResultArray();
|
||
|
||
$ids = [];
|
||
foreach ($rows as $row) {
|
||
$studentId = (int) ($row['student_id'] ?? 0);
|
||
if ($studentId <= 0 || isset($ids[$studentId])) {
|
||
continue;
|
||
}
|
||
if ($this->activeEnrollmentBlocksDuplicate($row)) {
|
||
$ids[$studentId] = true;
|
||
}
|
||
}
|
||
|
||
return $ids;
|
||
}
|
||
|
||
private function syncSiblingLastNameFlags(
|
||
array $students,
|
||
array $enrolledIds,
|
||
array $bypassCodesByStudent,
|
||
string $targetSchoolYear,
|
||
string $sourceSchoolYear,
|
||
?int $performedBy
|
||
): int {
|
||
$parentIds = [];
|
||
$studentsByParent = [];
|
||
foreach ($students as $student) {
|
||
$parentId = (int) ($student['parent_id'] ?? 0);
|
||
$studentId = (int) ($student['student_id'] ?? 0);
|
||
if ($parentId <= 0 || $studentId <= 0) {
|
||
continue;
|
||
}
|
||
$parentIds[$parentId] = $parentId;
|
||
$studentsByParent[$parentId][] = $studentId;
|
||
}
|
||
if ($parentIds === [] || ! $this->db->tableExists('students')) {
|
||
return 0;
|
||
}
|
||
|
||
$linked = $this->db->table('students')
|
||
->select('id, parent_id, lastname')
|
||
->whereIn('parent_id', array_values($parentIds))
|
||
->get()
|
||
->getResultArray();
|
||
|
||
$namesByParent = [];
|
||
foreach ($linked as $row) {
|
||
$parentId = (int) ($row['parent_id'] ?? 0);
|
||
$namesByParent[$parentId][] = $this->normalizeLastName($row['lastname'] ?? null);
|
||
}
|
||
|
||
$mismatchedParents = [];
|
||
foreach ($namesByParent as $parentId => $names) {
|
||
if (count($names) <= 1) {
|
||
continue;
|
||
}
|
||
$unique = array_values(array_unique($names));
|
||
if (in_array('', $unique, true) || count($unique) > 1) {
|
||
$mismatchedParents[$parentId] = true;
|
||
}
|
||
}
|
||
|
||
$written = 0;
|
||
foreach ($mismatchedParents as $parentId => $_) {
|
||
foreach ($studentsByParent[$parentId] ?? [] as $studentId) {
|
||
if (isset($enrolledIds[$studentId])) {
|
||
continue;
|
||
}
|
||
if ($this->flagCoveredByException('SIBLING_LAST_NAME_MISMATCH', $bypassCodesByStudent[$studentId] ?? [])) {
|
||
continue;
|
||
}
|
||
if ($this->upsertOpenFlag($studentId, $targetSchoolYear, $sourceSchoolYear, $this->flag('SIBLING_LAST_NAME_MISMATCH', 'high'), $performedBy)) {
|
||
$written++;
|
||
}
|
||
}
|
||
}
|
||
|
||
return $written;
|
||
}
|
||
|
||
private function syncFinancialFlags(
|
||
array $students,
|
||
array $enrolledIds,
|
||
array $bypassCodesByStudent,
|
||
string $targetSchoolYear,
|
||
string $sourceSchoolYear,
|
||
?int $performedBy
|
||
): int {
|
||
$target = $this->schoolYearByName($targetSchoolYear) ?? [];
|
||
$behavior = (string) ($target['carry_over_balance_behavior'] ?? 'submission_blocked_until_payment');
|
||
if (! in_array($behavior, ['submission_blocked_until_payment', 'admin_approval_required'], true)) {
|
||
return 0;
|
||
}
|
||
if (! $this->db->tableExists('invoices')) {
|
||
return 0;
|
||
}
|
||
|
||
$parentIds = [];
|
||
$studentsByParent = [];
|
||
foreach ($students as $student) {
|
||
$parentId = (int) ($student['parent_id'] ?? 0);
|
||
$studentId = (int) ($student['student_id'] ?? 0);
|
||
if ($parentId <= 0 || $studentId <= 0) {
|
||
continue;
|
||
}
|
||
$parentIds[$parentId] = $parentId;
|
||
$studentsByParent[$parentId][] = $studentId;
|
||
}
|
||
if ($parentIds === []) {
|
||
return 0;
|
||
}
|
||
|
||
$balances = $this->db->table('invoices')
|
||
->select('parent_id, COALESCE(SUM(balance), 0) AS balance', false)
|
||
->where('school_year', $sourceSchoolYear)
|
||
->whereIn('parent_id', array_values($parentIds))
|
||
->groupBy('parent_id')
|
||
->get()
|
||
->getResultArray();
|
||
|
||
$flagType = $behavior === 'admin_approval_required' ? 'FINANCIAL_REVIEW_REQUIRED' : 'FINANCIAL_REVIEW_REQUIRED';
|
||
$written = 0;
|
||
foreach ($balances as $row) {
|
||
if ((float) ($row['balance'] ?? 0) <= 0.0) {
|
||
continue;
|
||
}
|
||
$parentId = (int) ($row['parent_id'] ?? 0);
|
||
foreach ($studentsByParent[$parentId] ?? [] as $studentId) {
|
||
if (isset($enrolledIds[$studentId])) {
|
||
continue;
|
||
}
|
||
if ($this->flagCoveredByException($flagType, $bypassCodesByStudent[$studentId] ?? [])) {
|
||
continue;
|
||
}
|
||
if ($this->upsertOpenFlag(
|
||
$studentId,
|
||
$targetSchoolYear,
|
||
$sourceSchoolYear,
|
||
$this->flag($flagType, 'high', ['carry_over_balance' => (float) $row['balance']]),
|
||
$performedBy
|
||
)) {
|
||
$written++;
|
||
}
|
||
}
|
||
}
|
||
|
||
return $written;
|
||
}
|
||
|
||
private function appendFlagsFromRuleCodes(array &$evaluation): void
|
||
{
|
||
$existing = [];
|
||
foreach ($evaluation['flags'] ?? [] as $flag) {
|
||
$type = (string) ($flag['flag_type'] ?? '');
|
||
if ($type !== '') {
|
||
$existing[$type] = true;
|
||
}
|
||
}
|
||
|
||
$codes = array_values(array_unique(array_merge(
|
||
array_map('strval', $evaluation['blocking_rule_codes'] ?? []),
|
||
array_map('strval', $evaluation['review_rule_codes'] ?? [])
|
||
)));
|
||
|
||
$map = [
|
||
'NO_FINAL_DECISION' => ['DEFERRED_DELIBERATION', 'high'],
|
||
'UNRECOGNIZED_DECISION' => ['DEFERRED_DELIBERATION', 'high'],
|
||
'DEFERRED_DECISION' => ['DEFERRED_DELIBERATION', 'high'],
|
||
'EXPELLED' => ['RESTRICTED_ADMINISTRATIVE_REVIEW', 'high'],
|
||
'WITHDRAWN' => ['WITHDRAWAL_REVIEW_REQUIRED', 'normal'],
|
||
'DENIED' => ['RESTRICTED_ADMINISTRATIVE_REVIEW', 'high'],
|
||
'SIBLING_LAST_NAME_MISMATCH' => ['SIBLING_LAST_NAME_MISMATCH', 'high'],
|
||
'OUTSTANDING_BALANCE_BLOCKED' => ['FINANCIAL_REVIEW_REQUIRED', 'high'],
|
||
'FINANCE_APPROVAL_REQUIRED' => ['FINANCIAL_REVIEW_REQUIRED', 'high'],
|
||
'AGE_RULE_BLOCKED' => ['AGE_EXCEPTION_REQUIRED', 'normal'],
|
||
'ADULT_STUDENT_PARENT_BLOCKED' => ['ADULT_STUDENT_ACTION_REQUIRED', 'normal'],
|
||
'EXIT_REQUIRED' => ['COMPLETION_OR_EXIT_PROCESS_REQUIRED', 'normal'],
|
||
'REGISTRATION_CLOSED' => ['LATE_REGISTRATION_EXCEPTION', 'normal'],
|
||
];
|
||
|
||
foreach ($codes as $code) {
|
||
if (! isset($map[$code])) {
|
||
continue;
|
||
}
|
||
[$type, $priority] = $map[$code];
|
||
if (isset($existing[$type])) {
|
||
continue;
|
||
}
|
||
$evaluation['flags'][] = $this->flag($type, $priority, ['rule_code' => $code]);
|
||
$existing[$type] = true;
|
||
}
|
||
}
|
||
|
||
private function audit(array $evaluation, string $action, ?int $performedBy, ?array $original, array $new): void
|
||
{
|
||
if (! $this->db->tableExists('enrollment_transition_audits')) {
|
||
return;
|
||
}
|
||
|
||
$snapshot = [
|
||
'payload' => $new,
|
||
'decision' => $evaluation['decision'] ?? null,
|
||
'rule_codes' => $evaluation['rule_codes'] ?? [],
|
||
'blocking_rule_codes' => $evaluation['blocking_rule_codes'] ?? [],
|
||
'review_rule_codes' => $evaluation['review_rule_codes'] ?? [],
|
||
'warning_rule_codes' => $evaluation['warning_rule_codes'] ?? [],
|
||
'financial_summary' => $evaluation['financial_summary'] ?? null,
|
||
'exception_id' => $evaluation['admin_exception']['id'] ?? null,
|
||
'parent_id' => $evaluation['parent_id'] ?? ($new['parent_id'] ?? null),
|
||
];
|
||
|
||
$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($snapshot, JSON_UNESCAPED_SLASHES),
|
||
'reason' => implode(' ', $evaluation['blockers'] ?? []) ?: implode(' ', array_map('strval', $evaluation['rule_codes'] ?? [])),
|
||
'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';
|
||
}
|
||
|
||
private function ensureDecisionFields(array &$evaluation): void
|
||
{
|
||
$evaluation['rule_codes'] = array_values(array_unique(array_map('strval', $evaluation['rule_codes'] ?? [])));
|
||
$evaluation['blocking_rule_codes'] = array_values(array_unique(array_map('strval', $evaluation['blocking_rule_codes'] ?? [])));
|
||
$evaluation['warning_rule_codes'] = array_values(array_unique(array_map('strval', $evaluation['warning_rule_codes'] ?? [])));
|
||
$evaluation['review_rule_codes'] = array_values(array_unique(array_map('strval', $evaluation['review_rule_codes'] ?? [])));
|
||
$evaluation['admin_exception'] = $evaluation['admin_exception'] ?? null;
|
||
$evaluation['decision'] = $evaluation['decision'] ?? self::DECISION_REVIEW_REQUIRED;
|
||
$evaluation['can_enroll'] = (bool) ($evaluation['parent_enrollment_allowed'] ?? false);
|
||
|
||
foreach ($evaluation['blockers'] ?? [] as $message) {
|
||
$this->addDerivedBlockerCode($evaluation, (string) $message);
|
||
}
|
||
|
||
foreach ($evaluation['warnings'] ?? [] as $message) {
|
||
$this->addRuleCode($evaluation, $this->ruleCodeFromMessage((string) $message), 'warning');
|
||
}
|
||
}
|
||
|
||
private function deriveAcademicRuleCodes(array &$evaluation): void
|
||
{
|
||
$decision = (string) ($evaluation['deliberation_decision'] ?? '');
|
||
if (in_array($decision, [
|
||
DeliberationDecision::PASSED,
|
||
DeliberationDecision::REPEAT_CLASS,
|
||
DeliberationDecision::MAKE_UP_EXAM,
|
||
], true)) {
|
||
$this->addRuleCode($evaluation, $decision, $decision === DeliberationDecision::MAKE_UP_EXAM ? 'warning' : null);
|
||
}
|
||
|
||
if (($evaluation['placement_status'] ?? '') === 'exit_required') {
|
||
$this->addRuleCode($evaluation, 'EXIT_REQUIRED', 'blocking');
|
||
}
|
||
|
||
foreach ($evaluation['flags'] ?? [] as $flag) {
|
||
$flagType = (string) ($flag['flag_type'] ?? '');
|
||
if ($flagType !== '') {
|
||
$this->addRuleCode($evaluation, $flagType, 'warning');
|
||
}
|
||
}
|
||
}
|
||
|
||
private function addDerivedBlockerCode(array &$evaluation, string $message): void
|
||
{
|
||
$code = $this->ruleCodeFromMessage($message);
|
||
$this->addRuleCode($evaluation, $code, $this->reviewCode($code) ? 'review' : 'blocking');
|
||
}
|
||
|
||
private function ruleCodeFromMessage(string $message): string
|
||
{
|
||
$lower = strtolower($message);
|
||
|
||
return match (true) {
|
||
str_contains($lower, 'expelled') => 'EXPELLED',
|
||
str_contains($lower, 'withdrawn') => 'WITHDRAWN',
|
||
str_contains($lower, 'deferred') => 'DEFERRED_DECISION',
|
||
str_contains($lower, 'no final deliberation') || str_contains($lower, 'no final academic') || str_contains($lower, 'no final decision') => 'NO_FINAL_DECISION',
|
||
str_contains($lower, 'unrecognized') => 'UNRECOGNIZED_DECISION',
|
||
str_contains($lower, '18 years old') || str_contains($lower, 'adult-student') => 'ADULT_STUDENT_PARENT_BLOCKED',
|
||
str_contains($lower, 'registration for the new school year has not opened') => 'REGISTRATION_NOT_OPEN',
|
||
str_contains($lower, 'registration deadline') => 'REGISTRATION_CLOSED',
|
||
str_contains($lower, 'age rule') => 'AGE_RULE_BLOCKED',
|
||
str_contains($lower, 'closing school year') => 'SOURCE_YEAR_NOT_FOUND',
|
||
str_contains($lower, 'target school year') => 'TARGET_YEAR_NOT_FOUND',
|
||
str_contains($lower, 'highest available grade') || str_contains($lower, 'exit process') => 'EXIT_REQUIRED',
|
||
default => 'REVIEW_REQUIRED',
|
||
};
|
||
}
|
||
|
||
private function reviewCode(string $code): bool
|
||
{
|
||
return in_array($code, [
|
||
'NO_FINAL_DECISION',
|
||
'UNRECOGNIZED_DECISION',
|
||
'DEFERRED_DECISION',
|
||
'REVIEW_REQUIRED',
|
||
], true);
|
||
}
|
||
|
||
private function addBlocker(array &$evaluation, string $code, string $message): void
|
||
{
|
||
$blockers = array_values(array_filter(array_map('strval', $evaluation['blockers'] ?? [])));
|
||
if (! in_array($message, $blockers, true)) {
|
||
$blockers[] = $message;
|
||
}
|
||
$evaluation['blockers'] = $blockers;
|
||
$this->addRuleCode($evaluation, $code, $this->reviewCode($code) ? 'review' : 'blocking');
|
||
$evaluation['parent_enrollment_allowed'] = false;
|
||
$evaluation['can_enroll'] = false;
|
||
}
|
||
|
||
private function addRuleCode(array &$evaluation, string $code, ?string $bucket = null): void
|
||
{
|
||
$code = strtoupper(trim($code));
|
||
if ($code === '') {
|
||
return;
|
||
}
|
||
|
||
$evaluation['rule_codes'] = array_values(array_unique(array_merge($evaluation['rule_codes'] ?? [], [$code])));
|
||
if ($bucket !== null) {
|
||
$field = $bucket . '_rule_codes';
|
||
$evaluation[$field] = array_values(array_unique(array_merge($evaluation[$field] ?? [], [$code])));
|
||
}
|
||
}
|
||
|
||
private function removeRuleCode(array &$evaluation, string $code): void
|
||
{
|
||
$code = strtoupper(trim($code));
|
||
if ($code === '') {
|
||
return;
|
||
}
|
||
|
||
foreach (['rule_codes', 'blocking_rule_codes', 'warning_rule_codes', 'review_rule_codes'] as $field) {
|
||
$evaluation[$field] = array_values(array_filter(
|
||
array_map('strval', $evaluation[$field] ?? []),
|
||
static fn (string $existing): bool => $existing !== $code
|
||
));
|
||
}
|
||
|
||
$lastNameMessages = [
|
||
'Linked student last-name data must be reviewed by administration before re-enrollment.',
|
||
'Linked siblings have different last names. Please contact administration to review the family record.',
|
||
];
|
||
$evaluation['blockers'] = array_values(array_filter(
|
||
array_map('strval', $evaluation['blockers'] ?? []),
|
||
static fn (string $message): bool => ! in_array($message, $lastNameMessages, true)
|
||
));
|
||
}
|
||
|
||
private function activeEnrollmentBlocksDuplicate(?array $enrollment): bool
|
||
{
|
||
if ($enrollment === null) {
|
||
return false;
|
||
}
|
||
|
||
if ((int) ($enrollment['is_withdrawn'] ?? 0) === 1) {
|
||
return false;
|
||
}
|
||
|
||
$status = strtolower(trim((string) ($enrollment['enrollment_status'] ?? '')));
|
||
$admission = strtolower(trim((string) ($enrollment['admission_status'] ?? '')));
|
||
|
||
return $admission === 'accepted' || in_array($status, [
|
||
'admission under review',
|
||
'review & decision',
|
||
'payment pending',
|
||
'enrolled',
|
||
'waitlist',
|
||
'withdraw under review',
|
||
'refund pending',
|
||
], true);
|
||
}
|
||
|
||
private function deniedOrWithdrawnEnrollmentBlocksStandardEligibility(?array $enrollment): bool
|
||
{
|
||
if ($enrollment === null) {
|
||
return false;
|
||
}
|
||
|
||
$status = strtolower(trim((string) ($enrollment['enrollment_status'] ?? '')));
|
||
$admission = strtolower(trim((string) ($enrollment['admission_status'] ?? '')));
|
||
|
||
return $admission === 'denied'
|
||
|| in_array($status, ['denied', 'withdrawn', 'widthran'], true)
|
||
|| (int) ($enrollment['is_withdrawn'] ?? 0) === 1;
|
||
}
|
||
|
||
private function applyHouseholdLastNameRule(array &$evaluation, int $parentId): void
|
||
{
|
||
$students = $this->linkedStudentsForParent($parentId);
|
||
if (count($students) <= 1) {
|
||
$evaluation['family_name_check_ok'] = true;
|
||
return;
|
||
}
|
||
|
||
$normalized = [];
|
||
foreach ($students as $student) {
|
||
$lastName = $this->normalizeLastName($student['lastname'] ?? null);
|
||
if ($lastName === '') {
|
||
$this->addBlocker($evaluation, 'SIBLING_LAST_NAME_MISMATCH', 'Linked student last-name data must be reviewed by administration before re-enrollment.');
|
||
$evaluation['family_name_check_ok'] = false;
|
||
return;
|
||
}
|
||
$normalized[$lastName] = true;
|
||
}
|
||
|
||
$ok = count($normalized) === 1;
|
||
$evaluation['family_name_check_ok'] = $ok;
|
||
if (! $ok) {
|
||
$this->addBlocker($evaluation, 'SIBLING_LAST_NAME_MISMATCH', 'Linked siblings have different last names. Please contact administration to review the family record.');
|
||
}
|
||
}
|
||
|
||
private function applyCarriedForwardLastNameException(
|
||
array &$evaluation,
|
||
int $parentId,
|
||
int $studentId,
|
||
string $sourceSchoolYear,
|
||
string $targetSchoolYear
|
||
): void {
|
||
if (! in_array('SIBLING_LAST_NAME_MISMATCH', $evaluation['blocking_rule_codes'] ?? [], true)) {
|
||
return;
|
||
}
|
||
|
||
$financialBlockers = array_values(array_intersect(
|
||
$evaluation['blocking_rule_codes'] ?? [],
|
||
['OUTSTANDING_BALANCE_BLOCKED', 'FINANCE_APPROVAL_REQUIRED']
|
||
));
|
||
if ($financialBlockers !== []) {
|
||
return;
|
||
}
|
||
|
||
$prior = $this->priorLastNameException($parentId, $studentId, $targetSchoolYear, $sourceSchoolYear);
|
||
if ($prior === null) {
|
||
return;
|
||
}
|
||
|
||
$approvedStudentIds = $this->exceptionFamilyStudentIds($prior);
|
||
$currentStudentIds = $this->linkedStudentIds($parentId);
|
||
sort($currentStudentIds);
|
||
if ($approvedStudentIds !== [] && $approvedStudentIds !== $currentStudentIds) {
|
||
$evaluation['last_name_exception_carry_forward'] = [
|
||
'eligible' => false,
|
||
'prior_exception_id' => (int) ($prior['id'] ?? 0),
|
||
'prior_school_year' => (string) ($prior['school_year'] ?? ''),
|
||
'approved_student_ids' => $approvedStudentIds,
|
||
'reason' => 'NEW_STUDENT_ADDED',
|
||
];
|
||
return;
|
||
}
|
||
|
||
$this->removeRuleCode($evaluation, 'SIBLING_LAST_NAME_MISMATCH');
|
||
$this->addRuleCode($evaluation, 'LAST_NAME_EXCEPTION_CARRIED_FORWARD', 'warning');
|
||
$evaluation['warnings'] = array_values(array_unique(array_merge(
|
||
array_map('strval', $evaluation['warnings'] ?? []),
|
||
['A previous last-name exception still applies for this student.']
|
||
)));
|
||
$evaluation['family_name_check_ok'] = true;
|
||
$evaluation['last_name_exception_carry_forward'] = [
|
||
'eligible' => true,
|
||
'prior_exception_id' => (int) ($prior['id'] ?? 0),
|
||
'prior_school_year' => (string) ($prior['school_year'] ?? ''),
|
||
'approved_student_ids' => $approvedStudentIds !== [] ? $approvedStudentIds : [(int) ($prior['student_id'] ?? $studentId)],
|
||
];
|
||
}
|
||
|
||
public function linkedStudentIds(int $parentId): array
|
||
{
|
||
return array_values(array_unique(array_filter(array_map(
|
||
static fn (array $student): int => (int) ($student['id'] ?? 0),
|
||
$this->linkedStudentsForParent($parentId)
|
||
), static fn (int $id): bool => $id > 0)));
|
||
}
|
||
|
||
private function priorLastNameException(int $parentId, int $studentId, string $targetSchoolYear, string $sourceSchoolYear): ?array
|
||
{
|
||
if ($parentId <= 0 || $studentId <= 0 || ! $this->db->tableExists('enrollment_exceptions')) {
|
||
return null;
|
||
}
|
||
|
||
$rows = $this->db->table('enrollment_exceptions')
|
||
->where('parent_id', $parentId)
|
||
->where('student_id', $studentId)
|
||
->whereIn('status', ['active', 'used'])
|
||
->orderBy('id', 'DESC')
|
||
->get()
|
||
->getResultArray();
|
||
|
||
$sourceMatch = null;
|
||
$otherPrior = null;
|
||
foreach ($rows as $row) {
|
||
if ((int) ($row['student_id'] ?? 0) !== $studentId) {
|
||
continue;
|
||
}
|
||
$year = (string) ($row['school_year'] ?? '');
|
||
if ($year === '' || $year === $targetSchoolYear || ! $this->exceptionBypassesLastName($row)) {
|
||
continue;
|
||
}
|
||
|
||
if ($year === $sourceSchoolYear && $sourceMatch === null) {
|
||
$sourceMatch = $row;
|
||
} elseif ($otherPrior === null) {
|
||
$otherPrior = $row;
|
||
}
|
||
}
|
||
|
||
return $sourceMatch ?? $otherPrior;
|
||
}
|
||
|
||
private function exceptionBypassesLastName(array $exception): bool
|
||
{
|
||
$reason = strtoupper(trim((string) ($exception['reason_code'] ?? '')));
|
||
if (in_array($reason, ['SIBLING_LAST_NAME_MISMATCH', 'SIBLING_LAST_NAME_REVIEWED'], true)
|
||
|| str_contains($reason, 'LAST_NAME')
|
||
) {
|
||
return true;
|
||
}
|
||
|
||
$codes = json_decode((string) ($exception['bypassed_rule_codes_json'] ?? ''), true);
|
||
if (! is_array($codes)) {
|
||
return false;
|
||
}
|
||
|
||
$codes = array_map(static fn ($code): string => strtoupper(trim((string) $code)), $codes);
|
||
|
||
return in_array('SIBLING_LAST_NAME_MISMATCH', $codes, true);
|
||
}
|
||
|
||
/**
|
||
* @return list<int>
|
||
*/
|
||
private function exceptionFamilyStudentIds(array $exception): array
|
||
{
|
||
$ids = json_decode((string) ($exception['family_student_ids_json'] ?? ''), true);
|
||
if (! is_array($ids)) {
|
||
return [];
|
||
}
|
||
|
||
$ids = array_values(array_unique(array_filter(array_map(
|
||
static fn ($id): int => (int) $id,
|
||
$ids
|
||
), static fn (int $id): bool => $id > 0)));
|
||
sort($ids);
|
||
|
||
return $ids;
|
||
}
|
||
|
||
private function linkedStudentsForParent(int $parentId): array
|
||
{
|
||
if ($parentId <= 0 || ! $this->db->tableExists('students')) {
|
||
return [];
|
||
}
|
||
|
||
return $this->db->table('students')
|
||
->select('id, firstname, lastname, parent_id')
|
||
->where('parent_id', $parentId)
|
||
->orderBy('lastname', 'ASC')
|
||
->orderBy('firstname', 'ASC')
|
||
->get()
|
||
->getResultArray();
|
||
}
|
||
|
||
private function normalizeLastName(mixed $value): string
|
||
{
|
||
$value = strtolower(trim((string) $value));
|
||
$value = str_replace(['’', '`', '´'], "'", $value);
|
||
$value = preg_replace('/[^\p{L}\p{N}\s]+/u', '', $value) ?? '';
|
||
$value = preg_replace('/\s+/u', ' ', $value) ?? '';
|
||
|
||
return trim($value);
|
||
}
|
||
|
||
private function applyFinancialRule(array &$evaluation, int $parentId, string $sourceSchoolYear, string $targetSchoolYear): void
|
||
{
|
||
$summary = $this->financialSummary($parentId, $sourceSchoolYear, $targetSchoolYear);
|
||
$evaluation['financial_summary'] = $summary;
|
||
|
||
$balance = (float) ($summary['carry_over_balance'] ?? 0.0);
|
||
$behavior = (string) ($summary['balance_behavior'] ?? 'submission_blocked_until_payment');
|
||
if ($balance <= 0.0) {
|
||
return;
|
||
}
|
||
|
||
if ($behavior === 'admin_approval_required') {
|
||
$this->addBlocker($evaluation, 'FINANCE_APPROVAL_REQUIRED', 'Registration requires administrative financial approval because there is a previous-year balance.');
|
||
return;
|
||
}
|
||
|
||
$this->addBlocker($evaluation, 'OUTSTANDING_BALANCE_BLOCKED', 'Registration cannot be submitted until the previous-year balance is paid.');
|
||
}
|
||
|
||
private function financialSummary(int $parentId, string $sourceSchoolYear, string $targetSchoolYear): array
|
||
{
|
||
$target = $this->schoolYearByName($targetSchoolYear) ?? [];
|
||
$carryOver = $this->invoiceBalanceForParent($parentId, $sourceSchoolYear);
|
||
$current = $this->invoiceBalanceForParent($parentId, $targetSchoolYear);
|
||
$registrationFee = round((float) ($target['registration_fee'] ?? 0), 2);
|
||
$tuitionDue = round((float) ($target['tuition_due_at_registration'] ?? 0), 2);
|
||
$mandatory = round((float) ($target['mandatory_fees'] ?? 0), 2);
|
||
|
||
return [
|
||
'currency' => '$',
|
||
'source_school_year' => $sourceSchoolYear,
|
||
'target_school_year' => $targetSchoolYear,
|
||
'carry_over_balance' => round($carryOver, 2),
|
||
'current_balance' => round($current, 2),
|
||
'registration_fee' => $registrationFee,
|
||
'tuition_due_at_registration' => $tuitionDue,
|
||
'mandatory_fees' => $mandatory,
|
||
'amount_due' => round(max(0.0, $carryOver) + $registrationFee + $tuitionDue + $mandatory, 2),
|
||
'balance_behavior' => (string) ($target['carry_over_balance_behavior'] ?? 'submission_blocked_until_payment'),
|
||
'evaluated_at' => date('Y-m-d H:i:s'),
|
||
];
|
||
}
|
||
|
||
private function invoiceBalanceForParent(int $parentId, string $schoolYear): float
|
||
{
|
||
if ($parentId <= 0 || $schoolYear === '' || ! $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 applyScopedException(array &$evaluation, int $parentId, int $studentId, string $sourceSchoolYear, string $targetSchoolYear): void
|
||
{
|
||
$blockingCodes = array_values(array_unique(array_map('strval', $evaluation['blocking_rule_codes'] ?? [])));
|
||
$reviewCodes = array_values(array_unique(array_map('strval', $evaluation['review_rule_codes'] ?? [])));
|
||
$failedCodes = array_values(array_unique(array_merge($blockingCodes, $reviewCodes)));
|
||
if ($failedCodes === []) {
|
||
return;
|
||
}
|
||
|
||
$nonOverridable = array_values(array_intersect($failedCodes, self::NON_OVERRIDABLE_RULE_CODES));
|
||
if ($nonOverridable !== []) {
|
||
return;
|
||
}
|
||
|
||
if (in_array('REGISTRATION_CLOSED', $failedCodes, true)) {
|
||
$targetYear = $this->schoolYearByName($targetSchoolYear);
|
||
if ((int) ($targetYear['administrative_exceptions_permitted'] ?? 0) !== 1) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
$exception = $this->activeException($parentId, $studentId, $targetSchoolYear);
|
||
if ($exception === null) {
|
||
return;
|
||
}
|
||
|
||
$allowedCodes = json_decode((string) ($exception['bypassed_rule_codes_json'] ?? ''), true);
|
||
$allowedCodes = is_array($allowedCodes) ? array_values(array_filter(array_map('strval', $allowedCodes))) : [];
|
||
if ($allowedCodes === [] || array_diff($failedCodes, $allowedCodes) !== []) {
|
||
return;
|
||
}
|
||
|
||
$evaluation['admin_exception'] = [
|
||
'id' => (int) $exception['id'],
|
||
'reason_code' => (string) ($exception['reason_code'] ?? ''),
|
||
'source_school_year' => $sourceSchoolYear,
|
||
'bypassed_rule_codes' => $failedCodes,
|
||
];
|
||
$evaluation['decision'] = self::DECISION_EXCEPTION_ELIGIBLE;
|
||
$evaluation['parent_enrollment_allowed'] = true;
|
||
$evaluation['can_enroll'] = true;
|
||
}
|
||
|
||
private function activeException(int $parentId, int $studentId, string $schoolYear): ?array
|
||
{
|
||
if ($parentId <= 0 || $studentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('enrollment_exceptions')) {
|
||
return null;
|
||
}
|
||
|
||
$now = date('Y-m-d H:i:s');
|
||
|
||
return $this->db->table('enrollment_exceptions')
|
||
->where('parent_id', $parentId)
|
||
->where('student_id', $studentId)
|
||
->where('school_year', $schoolYear)
|
||
->where('status', 'active')
|
||
->groupStart()
|
||
->where('starts_at IS NULL', null, false)
|
||
->orWhere('starts_at <=', $now)
|
||
->groupEnd()
|
||
->groupStart()
|
||
->where('expires_at IS NULL', null, false)
|
||
->orWhere('expires_at >=', $now)
|
||
->groupEnd()
|
||
->orderBy('created_at', 'DESC')
|
||
->orderBy('id', 'DESC')
|
||
->limit(1)
|
||
->get()
|
||
->getRowArray() ?: null;
|
||
}
|
||
|
||
private function finalizeParentDecision(array &$evaluation): void
|
||
{
|
||
if (($evaluation['admin_exception'] ?? null) !== null && ! in_array('ALREADY_ENROLLED', $evaluation['blocking_rule_codes'] ?? [], true)) {
|
||
$evaluation['decision'] = self::DECISION_EXCEPTION_ELIGIBLE;
|
||
$evaluation['parent_enrollment_allowed'] = true;
|
||
$evaluation['can_enroll'] = true;
|
||
return;
|
||
}
|
||
|
||
if (in_array('ALREADY_ENROLLED', $evaluation['blocking_rule_codes'] ?? [], true)) {
|
||
$evaluation['decision'] = self::DECISION_ALREADY_ENROLLED;
|
||
$evaluation['parent_enrollment_allowed'] = false;
|
||
$evaluation['can_enroll'] = false;
|
||
return;
|
||
}
|
||
|
||
if (($evaluation['blocking_rule_codes'] ?? []) !== []) {
|
||
$evaluation['decision'] = self::DECISION_INELIGIBLE;
|
||
$evaluation['parent_enrollment_allowed'] = false;
|
||
$evaluation['can_enroll'] = false;
|
||
return;
|
||
}
|
||
|
||
if (($evaluation['review_rule_codes'] ?? []) !== []) {
|
||
$evaluation['decision'] = self::DECISION_REVIEW_REQUIRED;
|
||
$evaluation['parent_enrollment_allowed'] = false;
|
||
$evaluation['can_enroll'] = false;
|
||
return;
|
||
}
|
||
|
||
if (! empty($evaluation['warnings']) || ($evaluation['warning_rule_codes'] ?? []) !== []) {
|
||
$evaluation['decision'] = self::DECISION_ELIGIBLE_WITH_WARNING;
|
||
} else {
|
||
$evaluation['decision'] = self::DECISION_ELIGIBLE;
|
||
}
|
||
|
||
$evaluation['parent_enrollment_allowed'] = true;
|
||
$evaluation['can_enroll'] = true;
|
||
}
|
||
}
|