1098 lines
45 KiB
PHP
1098 lines
45 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Parents;
|
|
|
|
use App\Controllers\View\InvoiceController;
|
|
use App\Models\ConfigurationModel;
|
|
use App\Models\ParentPolicyAcceptanceModel;
|
|
use App\Models\StudentAllergyModel;
|
|
use App\Models\StudentClassModel;
|
|
use App\Models\StudentMedicalConditionModel;
|
|
use App\Models\StudentModel;
|
|
use App\Models\UserModel;
|
|
use App\Services\FeeCalculationService;
|
|
use App\Services\PhoneFormatterService;
|
|
use App\Support\Enrollment\DeliberationDecision;
|
|
use App\Support\Enrollment\EnrollmentEligibility;
|
|
use CodeIgniter\Database\BaseConnection;
|
|
use Throwable;
|
|
|
|
class ParentEnrollmentService
|
|
{
|
|
public function __construct(
|
|
private readonly BaseConnection $db,
|
|
private readonly UserModel $userModel,
|
|
private readonly StudentModel $studentModel,
|
|
private readonly StudentClassModel $studentClassModel,
|
|
private readonly ConfigurationModel $configModel,
|
|
private readonly StudentMedicalConditionModel $medicalConditionModel,
|
|
private readonly StudentAllergyModel $allergyModel,
|
|
private readonly ParentPolicyAcceptanceModel $policyAcceptanceModel,
|
|
private ?InvoiceController $invoiceController = null,
|
|
) {
|
|
}
|
|
|
|
public function enrollClassesData(
|
|
int $parentId,
|
|
string $selectedYear,
|
|
bool $isEditable,
|
|
?string $withdrawalDeadline,
|
|
?string $lastDayOfRegistration,
|
|
?string $schoolStartDate,
|
|
int $enrollmentStartStep,
|
|
string $studentsParam
|
|
): array {
|
|
$userData = $this->db->table('users')
|
|
->select('user_type, accept_school_policy, firstname, lastname, cellphone, address_street, apt, city, state, zip')
|
|
->where('id', $parentId)
|
|
->get()
|
|
->getRowArray();
|
|
|
|
if (! $userData || ($userData['user_type'] ?? '') !== 'primary') {
|
|
return [
|
|
'ok' => false,
|
|
'code' => 'invalid_user_type',
|
|
'message' => 'Invalid user type. Only primary parents can enroll.',
|
|
];
|
|
}
|
|
|
|
$students = $this->db->table('students')
|
|
->where('parent_id', $parentId)
|
|
->get()
|
|
->getResultArray();
|
|
|
|
if ($students === []) {
|
|
log_message('error', 'No students found for Parent ID: ' . $parentId);
|
|
return [
|
|
'ok' => false,
|
|
'code' => 'no_students',
|
|
'message' => 'No students found. Please register your child first.',
|
|
];
|
|
}
|
|
|
|
$previousSchoolYear = $this->previousSchoolYearName($selectedYear);
|
|
$fallMakeupExamOn = $this->fallMakeupExamDateForYear($selectedYear);
|
|
|
|
if ($previousSchoolYear !== null) {
|
|
service('enrollmentTransition')->syncParentFinancialReviewFlags(
|
|
$parentId,
|
|
$previousSchoolYear,
|
|
$selectedYear,
|
|
array_values(array_map(static fn (array $student): int => (int) ($student['id'] ?? 0), $students))
|
|
);
|
|
}
|
|
|
|
$this->attachEnrollmentDataToStudents($students, $parentId, $selectedYear, $previousSchoolYear, $fallMakeupExamOn, $isEditable);
|
|
$this->ensureStudentYearStatusRows($students, $selectedYear);
|
|
service('studentYearStatus')->attachToStudents($students, $selectedYear);
|
|
|
|
foreach ($students as &$student) {
|
|
$studentId = (int) ($student['id'] ?? 0);
|
|
if ($studentId > 0 && $this->isReturningReEnrollmentStudent($studentId, $selectedYear)) {
|
|
$student['is_new'] = 0;
|
|
}
|
|
}
|
|
unset($student);
|
|
|
|
if ($enrollmentStartStep < 1 || $enrollmentStartStep > 5) {
|
|
$enrollmentStartStep = 0;
|
|
}
|
|
|
|
$preselectedStudentIds = [];
|
|
if (trim($studentsParam) !== '') {
|
|
$preselectedStudentIds = array_values(array_unique(array_filter(
|
|
array_map('intval', explode(',', $studentsParam)),
|
|
static fn (int $id): bool => $id > 0
|
|
)));
|
|
}
|
|
|
|
return [
|
|
'ok' => true,
|
|
'data' => [
|
|
'students' => $students,
|
|
'selectedYear' => $selectedYear,
|
|
'previousSchoolYear' => $previousSchoolYear,
|
|
'fallMakeupExamOn' => $fallMakeupExamOn,
|
|
'isEditable' => $isEditable,
|
|
'withdrawalDeadline' => $withdrawalDeadline,
|
|
'lastDayOfRegistration' => $lastDayOfRegistration,
|
|
'schoolStartDate' => $schoolStartDate,
|
|
'hasAcceptedSchoolPolicy' => $this->hasAcceptedPolicyForYear($parentId, $selectedYear),
|
|
'familyFinancialSummary' => $this->familyFinancialSummary($parentId, $previousSchoolYear, $selectedYear, $students),
|
|
'enrollmentFeeSchedule' => $this->enrollmentFeeSchedule($selectedYear),
|
|
'parentContact' => $this->parentContactForEnrollment($userData),
|
|
'enrollmentStartStep' => $enrollmentStartStep,
|
|
'preselectedStudentIds' => $preselectedStudentIds,
|
|
],
|
|
];
|
|
}
|
|
|
|
public function eligibilityRefreshData(int $parentId, string $selectedYear): array
|
|
{
|
|
$previousSchoolYear = $this->previousSchoolYearName($selectedYear);
|
|
if ($previousSchoolYear === null) {
|
|
return ['students' => []];
|
|
}
|
|
|
|
$students = $this->db->table('students')
|
|
->select('id, firstname, lastname, dob')
|
|
->where('parent_id', $parentId)
|
|
->get()
|
|
->getResultArray();
|
|
|
|
$transitionService = service('enrollmentTransition');
|
|
$payload = [];
|
|
foreach ($students as $student) {
|
|
$studentId = (int) ($student['id'] ?? 0);
|
|
if ($studentId <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$existingEnrollment = $this->db->table('enrollments')
|
|
->select('enrollment_status, admission_status')
|
|
->where('student_id', $studentId)
|
|
->where('school_year', $selectedYear)
|
|
->orderBy('id', 'DESC')
|
|
->get()
|
|
->getRowArray();
|
|
$existingStatus = is_array($existingEnrollment) ? (string) ($existingEnrollment['enrollment_status'] ?? '') : '';
|
|
$existingAdmissionStatus = is_array($existingEnrollment) ? (string) ($existingEnrollment['admission_status'] ?? '') : '';
|
|
|
|
if ($this->hasSettledParentEnrollmentStatus($existingStatus, $existingAdmissionStatus)) {
|
|
$payload[] = [
|
|
'student_id' => $studentId,
|
|
'student_name' => $this->studentNameFromRow($student, 'Student ID ' . $studentId),
|
|
'can_enroll' => false,
|
|
'primary_block_reason' => 'ALREADY_ENROLLED',
|
|
'primary_parent_message' => EnrollmentEligibility::alreadyEnrolledMessage($existingStatus),
|
|
'block_title' => EnrollmentEligibility::alreadyEnrolledTitle($existingStatus),
|
|
'decision' => 'ALREADY_ENROLLED',
|
|
'blocking_rule_codes' => ['ALREADY_ENROLLED'],
|
|
];
|
|
continue;
|
|
}
|
|
|
|
$evaluation = $transitionService->evaluateForParent($parentId, $studentId, $previousSchoolYear, $selectedYear);
|
|
$payload[] = [
|
|
'student_id' => $studentId,
|
|
'student_name' => $this->studentNameFromRow($student, 'Student ID ' . $studentId),
|
|
'can_enroll' => (bool) ($evaluation['can_enroll'] ?? false),
|
|
'parent_enrollment_allowed' => (bool) ($evaluation['parent_enrollment_allowed'] ?? $evaluation['can_enroll'] ?? false),
|
|
'first_enrollment' => (bool) ($evaluation['first_enrollment'] ?? false),
|
|
'primary_block_reason' => $evaluation['primary_block_reason'] ?? null,
|
|
'primary_parent_message' => $evaluation['primary_parent_message'] ?? null,
|
|
'blocking_rule_codes' => array_values(array_map('strval', $evaluation['blocking_rule_codes'] ?? [])),
|
|
'decision' => $evaluation['decision'] ?? null,
|
|
];
|
|
}
|
|
|
|
$financialSummary = $transitionService->getEnrollmentFinancialSummary($parentId, $previousSchoolYear, $selectedYear);
|
|
$transitionService->syncParentFinancialReviewFlags(
|
|
$parentId,
|
|
$previousSchoolYear,
|
|
$selectedYear,
|
|
array_values(array_map(static fn (array $student): int => (int) ($student['id'] ?? 0), $students))
|
|
);
|
|
|
|
return [
|
|
'students' => $payload,
|
|
'financial_summary' => [
|
|
'carry_forward_balance' => (float) ($financialSummary['carry_forward_balance'] ?? 0),
|
|
'current_year_balance' => (float) ($financialSummary['current_year_balance'] ?? 0),
|
|
'total_enrollment_due' => (float) ($financialSummary['total_enrollment_due'] ?? 0),
|
|
],
|
|
];
|
|
}
|
|
|
|
public function hasAcceptedPolicyForYear(int $parentId, string $schoolYear): bool
|
|
{
|
|
if ($parentId <= 0 || $schoolYear === '') {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
return $this->policyAcceptanceModel->hasAccepted($parentId, $schoolYear);
|
|
} catch (Throwable $e) {
|
|
log_message('error', 'Failed to read parent policy acceptance: {message}', [
|
|
'message' => $e->getMessage(),
|
|
]);
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public function recordPolicyAcceptance(int $parentId, string $schoolYear, string $source, string $ipAddress, string $userAgent): void
|
|
{
|
|
if ($parentId <= 0 || $schoolYear === '') {
|
|
throw new \RuntimeException('Unable to record school policy acceptance.');
|
|
}
|
|
|
|
if (! $this->policyAcceptanceModel->recordAcceptance($parentId, $schoolYear, $source, $ipAddress, $userAgent)) {
|
|
throw new \RuntimeException('Unable to record school policy acceptance.');
|
|
}
|
|
}
|
|
|
|
public function updateEnrollmentStudentInfo(array $studentIds, int $parentId, array $studentInfo): array
|
|
{
|
|
if ($studentInfo === []) {
|
|
return [];
|
|
}
|
|
|
|
$submittedIds = array_fill_keys(array_map('intval', $studentIds), true);
|
|
$errors = [];
|
|
$updates = [];
|
|
|
|
foreach ($studentInfo as $studentId => $fields) {
|
|
$studentId = (int) $studentId;
|
|
if ($studentId <= 0 || ! isset($submittedIds[$studentId]) || ! is_array($fields)) {
|
|
continue;
|
|
}
|
|
|
|
$existing = $this->studentModel
|
|
->where('id', $studentId)
|
|
->where('parent_id', $parentId)
|
|
->first();
|
|
|
|
if (! is_array($existing)) {
|
|
$errors[] = 'Student ID ' . $studentId . ': student record was not found.';
|
|
continue;
|
|
}
|
|
|
|
$studentLabel = trim((string) ($existing['firstname'] ?? '') . ' ' . (string) ($existing['lastname'] ?? ''))
|
|
?: 'Student ID ' . $studentId;
|
|
$photoConsent = (string) ($fields['photo_consent'] ?? '');
|
|
|
|
if ($photoConsent !== '0' && $photoConsent !== '1') {
|
|
$errors[] = $studentLabel . ': photo consent is required.';
|
|
continue;
|
|
}
|
|
|
|
$medicalConditions = $this->normalizeEnrollmentHealthSelections(
|
|
$fields['medical_conditions'] ?? [],
|
|
(string) ($fields['medical_condition_other'] ?? '')
|
|
);
|
|
$allergies = $this->normalizeEnrollmentHealthSelections(
|
|
$fields['allergies'] ?? [],
|
|
(string) ($fields['allergy_other'] ?? '')
|
|
);
|
|
|
|
if ($medicalConditions === []) {
|
|
$errors[] = $studentLabel . ': medical conditions are required.';
|
|
continue;
|
|
}
|
|
|
|
if ($allergies === []) {
|
|
$errors[] = $studentLabel . ': allergies are required.';
|
|
continue;
|
|
}
|
|
|
|
$updates[$studentId] = [
|
|
'photo_consent' => (int) $photoConsent,
|
|
'medical_conditions' => $medicalConditions,
|
|
'allergies' => $allergies,
|
|
];
|
|
}
|
|
|
|
if ($errors !== []) {
|
|
return $errors;
|
|
}
|
|
|
|
foreach ($updates as $studentId => $payload) {
|
|
if (! $this->studentModel->update($studentId, ['photo_consent' => $payload['photo_consent']])) {
|
|
$errors[] = 'Student ID ' . $studentId . ': student information could not be updated.';
|
|
continue;
|
|
}
|
|
|
|
$this->medicalConditionModel->where('student_id', $studentId)->delete();
|
|
foreach ($payload['medical_conditions'] as $condition) {
|
|
$this->medicalConditionModel->insert([
|
|
'student_id' => $studentId,
|
|
'condition_name' => $condition,
|
|
]);
|
|
}
|
|
|
|
$this->allergyModel->where('student_id', $studentId)->delete();
|
|
foreach ($payload['allergies'] as $allergy) {
|
|
$this->allergyModel->insert([
|
|
'student_id' => $studentId,
|
|
'allergy' => $allergy,
|
|
]);
|
|
}
|
|
}
|
|
|
|
return $errors;
|
|
}
|
|
|
|
public function updateEnrollmentParentContact(int $parentId, array $fields): array
|
|
{
|
|
$currentState = '';
|
|
$user = $this->userModel->find($parentId);
|
|
if (is_array($user)) {
|
|
$currentState = strtoupper(trim((string) ($user['state'] ?? '')));
|
|
}
|
|
|
|
$normalized = $this->normalizeEnrollmentParentContact($fields, $currentState);
|
|
if ($normalized['errors'] !== []) {
|
|
return $normalized['errors'];
|
|
}
|
|
|
|
if (! $this->userModel->update($parentId, $normalized['data'])) {
|
|
return ['Parent contact information could not be updated.'];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
public function normalizeEnrollmentParentContact(array $fields, string $existingState = ''): array
|
|
{
|
|
$phoneDigits = preg_replace('/\D/', '', (string) ($fields['cellphone'] ?? '')) ?? '';
|
|
$street = $this->collapseContactWhitespace((string) ($fields['address_street'] ?? ''));
|
|
$apt = $this->collapseContactWhitespace((string) ($fields['apt'] ?? ''));
|
|
$city = $this->collapseContactWhitespace((string) ($fields['city'] ?? ''));
|
|
$state = strtoupper(trim((string) ($fields['state'] ?? '')));
|
|
$zip = preg_replace('/\D/', '', (string) ($fields['zip'] ?? '')) ?? '';
|
|
$errors = [];
|
|
$allowedStates = ['CT', 'ME', 'MA', 'NH', 'NY', 'RI', 'VT'];
|
|
$existingState = strtoupper(trim($existingState));
|
|
if (preg_match('/^[A-Z]{2}$/', $existingState) === 1) {
|
|
$allowedStates[] = $existingState;
|
|
}
|
|
|
|
if (strlen($phoneDigits) !== 10) {
|
|
$errors[] = 'A valid 10-digit home/cell phone number is required.';
|
|
}
|
|
|
|
if ($street === '' || strlen($street) < 3 || strlen($street) > 50 || ! preg_match('/^[A-Za-z0-9.\s\-]+$/', $street)) {
|
|
$errors[] = 'Home street address must be 3-50 characters and may contain only letters, numbers, spaces, periods, and hyphens.';
|
|
}
|
|
|
|
if ($apt !== '' && (strlen($apt) > 15 || ! preg_match('/^[A-Za-z0-9.\s\-]+$/', $apt))) {
|
|
$errors[] = 'Apartment or unit must be 15 characters or fewer and may contain only letters, numbers, spaces, periods, and hyphens.';
|
|
}
|
|
|
|
if (strlen($city) < 2 || strlen($city) > 30 || ! preg_match('/^[A-Za-z\s.\'\-]+$/', $city)) {
|
|
$errors[] = 'City must be 2-30 characters and may contain only letters, spaces, periods, apostrophes, and hyphens.';
|
|
}
|
|
|
|
if (! preg_match('/^[A-Z]{2}$/', $state) || ! in_array($state, $allowedStates, true)) {
|
|
$errors[] = 'State is required.';
|
|
}
|
|
|
|
if (! preg_match('/^\d{5}$/', $zip)) {
|
|
$errors[] = 'A valid 5-digit ZIP code is required.';
|
|
}
|
|
|
|
if ($errors !== []) {
|
|
return ['errors' => $errors, 'data' => []];
|
|
}
|
|
|
|
$formattedPhone = (new PhoneFormatterService())->formatPhoneNumber($phoneDigits);
|
|
|
|
return [
|
|
'errors' => [],
|
|
'data' => [
|
|
'cellphone' => $formattedPhone ?: $phoneDigits,
|
|
'address_street' => ucwords(strtolower($street)),
|
|
'apt' => strtoupper($apt),
|
|
'city' => ucwords(strtolower($city), " -'"),
|
|
'state' => $state,
|
|
'zip' => $zip,
|
|
'updated_at' => utc_now(),
|
|
],
|
|
];
|
|
}
|
|
|
|
public function generateInvoiceForParentEnrollment(int $parentId, string $schoolYear, string $semester): array
|
|
{
|
|
if ($parentId <= 0) {
|
|
return ['ok' => false, 'message' => 'Enrollment was submitted, but the parent invoice could not be generated.'];
|
|
}
|
|
|
|
try {
|
|
$invoiceController = $this->invoiceController ??= new InvoiceController();
|
|
$result = $invoiceController->generateInvoice((string) $parentId, $schoolYear, $semester);
|
|
} catch (Throwable $e) {
|
|
log_message('error', 'Invoice generation failed after parent enrollment: {message}', [
|
|
'message' => $e->getMessage(),
|
|
'parentId' => $parentId,
|
|
'schoolYear' => $schoolYear,
|
|
'semester' => $semester,
|
|
]);
|
|
|
|
return ['ok' => false, 'message' => 'Enrollment was submitted, but the invoice could not be generated. Please contact the school administration.'];
|
|
}
|
|
|
|
if (is_array($result) && ! empty($result['ok'])) {
|
|
return [
|
|
'ok' => true,
|
|
'message' => ! empty($result['updated']) ? 'Invoice updated.' : 'Invoice generated.',
|
|
];
|
|
}
|
|
|
|
$message = is_array($result) ? (string) ($result['message'] ?? '') : '';
|
|
if ($message === 'Invoice requires at least one non-zero line.') {
|
|
log_message('info', 'No invoice generated after parent enrollment because no billable invoice lines exist yet for parent {parentId}, year {schoolYear}.', [
|
|
'parentId' => $parentId,
|
|
'schoolYear' => $schoolYear,
|
|
]);
|
|
|
|
return ['ok' => true, 'message' => 'No invoice was generated yet because there are no billable enrollment charges.'];
|
|
}
|
|
|
|
log_message('error', 'Invoice generation returned an unsuccessful result after parent enrollment: {result}', [
|
|
'result' => json_encode($result),
|
|
'parentId' => $parentId,
|
|
'schoolYear' => $schoolYear,
|
|
'semester' => $semester,
|
|
]);
|
|
|
|
return ['ok' => false, 'message' => 'Enrollment was submitted, but the invoice could not be generated. Please contact the school administration.'];
|
|
}
|
|
|
|
private function attachEnrollmentDataToStudents(
|
|
array &$students,
|
|
int $parentId,
|
|
string $selectedYear,
|
|
?string $previousSchoolYear,
|
|
?string $fallMakeupExamOn,
|
|
bool $isEditable
|
|
): void {
|
|
$statusMap = [
|
|
'admission under review' => 'admission under review',
|
|
'review & decision' => 'review & decision',
|
|
'payment pending' => 'payment pending',
|
|
'enrolled' => 'enrolled',
|
|
'withdraw under review' => 'withdraw under review',
|
|
'refund pending' => 'refund pending',
|
|
'withdrawn' => 'withdrawn',
|
|
'denied' => 'denied',
|
|
'waitlist' => 'waitlist',
|
|
];
|
|
|
|
foreach ($students as &$student) {
|
|
$studentId = (int) ($student['id'] ?? 0);
|
|
$student['age'] = $this->calculateAgeAsOfSchoolYearStartYear($student['dob'] ?? null, $selectedYear);
|
|
$student['allergies'] = $this->allergyModel->where('student_id', $studentId)->findColumn('allergy') ?? [];
|
|
$student['medical_conditions'] = $this->medicalConditionModel->where('student_id', $studentId)->findColumn('condition_name') ?? [];
|
|
|
|
$classSections = $this->studentClassModel->getClassSectionsByStudentId($studentId, $selectedYear, true);
|
|
$student['class_section'] = $classSections !== [] ? implode(', ', $classSections) : 'Class not Assigned';
|
|
|
|
$enrollment = $this->db->table('enrollments')
|
|
->select('enrollment_status, admission_status')
|
|
->where('student_id', $studentId)
|
|
->where('school_year', $selectedYear)
|
|
->get()
|
|
->getRowArray();
|
|
|
|
if ($enrollment && isset($enrollment['admission_status'])) {
|
|
$student['admission_status'] = $enrollment['admission_status'];
|
|
$student['enrollment_status'] = $enrollment['admission_status'] === 'denied'
|
|
? 'denied'
|
|
: ($statusMap[$enrollment['enrollment_status']] ?? 'not enrolled');
|
|
} else {
|
|
$student['admission_status'] = null;
|
|
$student['enrollment_status'] = 'not enrolled';
|
|
}
|
|
|
|
$student['disable_enroll'] = in_array(
|
|
$student['enrollment_status'],
|
|
['admission under review', 'review & decision', 'payment pending', 'enrolled', 'withdraw under review', 'denied'],
|
|
true
|
|
);
|
|
|
|
$decisionYear = $isEditable ? $previousSchoolYear : $selectedYear;
|
|
$student['previous_year_decision'] = $decisionYear !== null
|
|
? $this->studentDecisionForYear($studentId, $decisionYear)
|
|
: null;
|
|
|
|
if ($isEditable) {
|
|
$student['transition_evaluation'] = $previousSchoolYear !== null
|
|
? $this->transitionEvaluationForStudent($parentId, $studentId, $previousSchoolYear, $selectedYear)
|
|
: null;
|
|
$student['enrollment_eligibility_message'] = $this->eligibilityMessageFromTransition(
|
|
$student,
|
|
$student['transition_evaluation'],
|
|
$fallMakeupExamOn,
|
|
$selectedYear
|
|
);
|
|
$student['expected_placement_label'] = $this->expectedPlacementLabel($student['transition_evaluation']);
|
|
$student['required_action_label'] = $this->requiredActionLabel($student['transition_evaluation']);
|
|
$student['parent_enrollment_state'] = $this->parentEnrollmentState($student);
|
|
} else {
|
|
$student['transition_evaluation'] = $this->readonlyEnrollmentEvaluation($student['previous_year_decision']);
|
|
$student['enrollment_eligibility_message'] = ['message' => '', 'blocking' => false, 'level' => 'info'];
|
|
$student['expected_placement_label'] = (string) ($student['class_section'] ?? 'Class not Assigned');
|
|
$student['required_action_label'] = 'Read-only closed school year.';
|
|
$student['parent_enrollment_state'] = $this->parentEnrollmentState($student);
|
|
}
|
|
}
|
|
unset($student);
|
|
}
|
|
|
|
private function parentContactForEnrollment(array $user): array
|
|
{
|
|
$posted = old('parent_contact');
|
|
$posted = is_array($posted) ? $posted : [];
|
|
$phoneDigits = preg_replace('/\D/', '', (string) ($posted['cellphone'] ?? $user['cellphone'] ?? '')) ?? '';
|
|
$phoneDisplay = strlen($phoneDigits) === 10
|
|
? substr($phoneDigits, 0, 3) . '-' . substr($phoneDigits, 3, 3) . '-' . substr($phoneDigits, 6)
|
|
: trim((string) ($posted['cellphone'] ?? $user['cellphone'] ?? ''));
|
|
|
|
return [
|
|
'firstname' => trim((string) ($user['firstname'] ?? '')),
|
|
'lastname' => trim((string) ($user['lastname'] ?? '')),
|
|
'cellphone' => $phoneDisplay,
|
|
'address_street' => trim((string) ($posted['address_street'] ?? $user['address_street'] ?? '')),
|
|
'apt' => trim((string) ($posted['apt'] ?? $user['apt'] ?? '')),
|
|
'city' => trim((string) ($posted['city'] ?? $user['city'] ?? '')),
|
|
'state' => strtoupper(trim((string) ($posted['state'] ?? $user['state'] ?? ''))),
|
|
'zip' => trim((string) ($posted['zip'] ?? $user['zip'] ?? '')),
|
|
];
|
|
}
|
|
|
|
private function normalizeEnrollmentHealthSelections($selected, string $otherText): array
|
|
{
|
|
$values = [];
|
|
foreach ((array) $selected as $value) {
|
|
$value = trim((string) $value);
|
|
if ($value !== '') {
|
|
$values[] = $value;
|
|
}
|
|
}
|
|
|
|
$otherText = trim($otherText);
|
|
if (in_array('Other', $values, true)) {
|
|
$values = array_values(array_filter($values, static fn (string $value): bool => $value !== 'Other'));
|
|
if ($otherText !== '') {
|
|
$values[] = mb_substr($otherText, 0, 100);
|
|
}
|
|
}
|
|
|
|
return array_values(array_combine($values, $values) ?: []);
|
|
}
|
|
|
|
private function collapseContactWhitespace(string $value): string
|
|
{
|
|
return trim(preg_replace('/\s+/', ' ', $value) ?? '');
|
|
}
|
|
|
|
private function calculateAgeAsOfSchoolYearStartYear(?string $dob, string $schoolYear): ?int
|
|
{
|
|
$dob = trim((string) $dob);
|
|
$schoolYear = trim($schoolYear);
|
|
|
|
if ($dob === '' || ! preg_match('/^(\d{4})/', $schoolYear, $matches)) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$timezone = new \DateTimeZone((string) (config('School')->attendance['timezone'] ?? user_timezone()));
|
|
$birthDate = \DateTimeImmutable::createFromFormat('!Y-m-d', $dob, $timezone);
|
|
$errors = \DateTimeImmutable::getLastErrors();
|
|
$hasParseErrors = is_array($errors)
|
|
&& (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0);
|
|
|
|
if ($birthDate === false || $hasParseErrors) {
|
|
return null;
|
|
}
|
|
|
|
$schoolYearStartYearCutoff = new \DateTimeImmutable($matches[1] . '-09-01', $timezone);
|
|
|
|
if ($birthDate > $schoolYearStartYearCutoff) {
|
|
return null;
|
|
}
|
|
|
|
return $birthDate->diff($schoolYearStartYearCutoff)->y;
|
|
} catch (Throwable $e) {
|
|
log_message('warning', 'Unable to calculate school-year age from DOB: {message}', [
|
|
'message' => $e->getMessage(),
|
|
]);
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private function studentDecisionForYear(int $studentId, string $schoolYear): ?array
|
|
{
|
|
if ($studentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('student_decisions')) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return $this->db->table('student_decisions')
|
|
->select('decision, source, notes, class_section_name')
|
|
->where('student_id', $studentId)
|
|
->where('school_year', $schoolYear)
|
|
->orderBy('updated_at', 'DESC')
|
|
->orderBy('id', 'DESC')
|
|
->limit(1)
|
|
->get()
|
|
->getRowArray() ?: null;
|
|
} catch (Throwable $e) {
|
|
log_message('error', 'studentDecisionForYear failed: ' . $e->getMessage());
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private function transitionEvaluationForStudent(int $parentId, int $studentId, string $previousSchoolYear, string $selectedYear): ?array
|
|
{
|
|
try {
|
|
return service('enrollmentTransition')->evaluateForParent($parentId, $studentId, $previousSchoolYear, $selectedYear, 'parent');
|
|
} catch (Throwable $e) {
|
|
log_message('error', 'Enrollment transition evaluation failed for student ' . $studentId . ': ' . $e->getMessage());
|
|
|
|
return [
|
|
'blockers' => ['Enrollment eligibility could not be evaluated. Please contact the administration.'],
|
|
'warnings' => [],
|
|
'placement_status' => 'unknown',
|
|
'deliberation_decision' => null,
|
|
'parent_enrollment_allowed' => false,
|
|
'student_self_enrollment_allowed' => false,
|
|
'assigned_grade_id' => null,
|
|
'assigned_class_section_id' => null,
|
|
];
|
|
}
|
|
}
|
|
|
|
private function readonlyEnrollmentEvaluation(?array $decisionRow): array
|
|
{
|
|
$decision = DeliberationDecision::normalize($decisionRow['deliberation_decision_standard'] ?? null)
|
|
?? DeliberationDecision::normalize($decisionRow['decision'] ?? null);
|
|
|
|
return [
|
|
'blockers' => [],
|
|
'warnings' => [],
|
|
'placement_status' => 'readonly',
|
|
'deliberation_decision' => $decision,
|
|
'decision_label' => DeliberationDecision::display($decisionRow['decision'] ?? '') ?: 'Pending',
|
|
'parent_enrollment_allowed' => false,
|
|
'student_self_enrollment_allowed' => false,
|
|
'assigned_grade_id' => null,
|
|
'assigned_class_section_id' => null,
|
|
];
|
|
}
|
|
|
|
private function eligibilityMessageFromTransition(array $student, ?array $evaluation, ?string $fallMakeupExamOn, string $selectedYear): array
|
|
{
|
|
if ($this->hasSettledParentEnrollmentStatus(
|
|
(string) ($student['enrollment_status'] ?? ''),
|
|
(string) ($student['admission_status'] ?? '')
|
|
)) {
|
|
return [
|
|
'message' => EnrollmentEligibility::alreadyEnrolledMessage((string) ($student['enrollment_status'] ?? '')),
|
|
'blocking' => true,
|
|
'level' => 'info',
|
|
'primary_block_reason' => 'ALREADY_ENROLLED',
|
|
];
|
|
}
|
|
|
|
if ($evaluation === null) {
|
|
return EnrollmentEligibility::parentDecisionMessage(
|
|
$student,
|
|
$student['previous_year_decision'] ?? null,
|
|
$selectedYear,
|
|
$fallMakeupExamOn
|
|
);
|
|
}
|
|
|
|
if (! empty($evaluation['can_enroll']) && ! empty($evaluation['admin_exception'])) {
|
|
return ['message' => 'Enrollment has been authorized by administration.', 'blocking' => false, 'level' => 'info'];
|
|
}
|
|
|
|
if (($evaluation['can_enroll'] ?? false) === false && ! empty($evaluation['primary_parent_message'])) {
|
|
return [
|
|
'message' => (string) $evaluation['primary_parent_message'],
|
|
'blocking' => true,
|
|
'level' => 'danger',
|
|
'primary_block_reason' => $evaluation['primary_block_reason'] ?? null,
|
|
];
|
|
}
|
|
|
|
$blockers = array_values(array_filter(array_map('trim', array_map('strval', $evaluation['blockers'] ?? []))));
|
|
if ($blockers !== []) {
|
|
$name = $this->studentNameFromRow($student);
|
|
$blockers = array_map(fn (string $message): string => $this->messageWithStudentName($name, $message), $blockers);
|
|
|
|
return ['message' => implode(' ', $blockers), 'blocking' => true, 'level' => 'danger'];
|
|
}
|
|
|
|
$warnings = array_values(array_filter(array_map('trim', array_map('strval', $evaluation['warnings'] ?? []))));
|
|
if ($warnings !== []) {
|
|
return ['message' => implode(' ', $warnings), 'blocking' => false, 'level' => 'warning'];
|
|
}
|
|
|
|
if ((string) ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM) {
|
|
$name = $this->studentNameFromRow($student);
|
|
$dateText = $fallMakeupExamOn !== null ? ' on ' . local_date($fallMakeupExamOn, 'm-d-Y') : '';
|
|
|
|
return [
|
|
'message' => $name . ' may complete enrollment now and will initially remain in the same grade until the make-up exam' . $dateText . ' is resolved by administration.',
|
|
'blocking' => false,
|
|
'level' => 'warning',
|
|
];
|
|
}
|
|
|
|
return ['message' => '', 'blocking' => false, 'level' => 'info'];
|
|
}
|
|
|
|
private function expectedPlacementLabel(?array $evaluation): string
|
|
{
|
|
if ($evaluation === null || (($evaluation['blockers'] ?? []) !== [] && empty($evaluation['assigned_grade_id']))) {
|
|
return 'Pending';
|
|
}
|
|
|
|
return match ((string) ($evaluation['placement_status'] ?? '')) {
|
|
'automatic_distribution_pending' => $this->gradeLabel($this->classNameById((int) ($evaluation['assigned_grade_id'] ?? 0))),
|
|
'same_class_assigned', 'temporary_same_grade' => $this->classSectionNameById((int) ($evaluation['assigned_class_section_id'] ?? 0)) ?: 'Same grade',
|
|
'manual_class_required' => 'Same grade - administration must assign class',
|
|
'temporary_manual_class_required' => 'Same grade initially - administration must assign temporary class',
|
|
'exit_required' => 'Completion or exit process required',
|
|
default => 'Pending',
|
|
};
|
|
}
|
|
|
|
private function requiredActionLabel(?array $evaluation): string
|
|
{
|
|
if ($evaluation === null) {
|
|
return 'Complete re-enrollment before the registration deadline.';
|
|
}
|
|
|
|
return match ($this->parentEnrollmentStateFromEvaluation($evaluation, null)) {
|
|
'Enroll' => 'Complete re-enrollment before the registration deadline.',
|
|
'Eligible with follow-up' => 'Complete re-enrollment and follow the listed next step.',
|
|
'Already submitted' => 'Already submitted',
|
|
'Action needed' => 'Action needed: pay the previous-year balance or contact administration.',
|
|
'Under review' => 'Contact the school administration.',
|
|
default => 'Contact administration',
|
|
};
|
|
}
|
|
|
|
private function parentEnrollmentState(array $student): string
|
|
{
|
|
$status = strtolower(trim((string) ($student['enrollment_status'] ?? '')));
|
|
if ($this->hasSettledParentEnrollmentStatus($status, (string) ($student['admission_status'] ?? ''))) {
|
|
return 'Already submitted';
|
|
}
|
|
|
|
return $this->parentEnrollmentStateFromEvaluation(
|
|
is_array($student['transition_evaluation'] ?? null) ? $student['transition_evaluation'] : null,
|
|
$status
|
|
);
|
|
}
|
|
|
|
private function parentEnrollmentStateFromEvaluation(?array $evaluation, ?string $enrollmentStatus): string
|
|
{
|
|
if ($evaluation === null) {
|
|
return 'Contact administration';
|
|
}
|
|
|
|
$decision = (string) ($evaluation['decision'] ?? '');
|
|
$codes = array_map('strval', $evaluation['blocking_rule_codes'] ?? []);
|
|
|
|
if ($decision === 'ALREADY_ENROLLED' || $enrollmentStatus === 'already enrolled') {
|
|
return 'Already submitted';
|
|
}
|
|
|
|
if (! empty($evaluation['can_enroll']) || ! empty($evaluation['parent_enrollment_allowed']) || $decision === 'EXCEPTION_ELIGIBLE' || $decision === 'ELIGIBLE') {
|
|
return $decision === 'ELIGIBLE_WITH_WARNING' || ($evaluation['warning_rule_codes'] ?? []) !== []
|
|
? 'Eligible with follow-up'
|
|
: 'Enroll';
|
|
}
|
|
|
|
if ($decision === 'ELIGIBLE_WITH_WARNING') {
|
|
return 'Eligible with follow-up';
|
|
}
|
|
|
|
if (in_array('OUTSTANDING_BALANCE_BLOCKED', $codes, true)
|
|
|| in_array('FINANCE_APPROVAL_REQUIRED', $codes, true)
|
|
|| in_array('SIBLING_LAST_NAME_MISMATCH', $codes, true)
|
|
) {
|
|
return 'Action needed';
|
|
}
|
|
|
|
if ($decision === 'REVIEW_REQUIRED' || in_array((string) ($evaluation['deliberation_decision'] ?? ''), [
|
|
DeliberationDecision::EXPELLED,
|
|
DeliberationDecision::WITHDRAWN,
|
|
DeliberationDecision::DEFERRED_DECISION,
|
|
], true)) {
|
|
return 'Under review';
|
|
}
|
|
|
|
return 'Contact administration';
|
|
}
|
|
|
|
private function hasSettledParentEnrollmentStatus(string $status, string $admissionStatus = ''): bool
|
|
{
|
|
if (strtolower(trim($admissionStatus)) === 'accepted') {
|
|
return true;
|
|
}
|
|
|
|
return in_array(strtolower(trim($status)), [
|
|
'admission under review',
|
|
'review & decision',
|
|
'payment pending',
|
|
'enrolled',
|
|
'waitlist',
|
|
'withdraw under review',
|
|
'refund pending',
|
|
], true);
|
|
}
|
|
|
|
private function familyFinancialSummary(int $parentId, ?string $previousSchoolYear, string $selectedYear, array $students = []): array
|
|
{
|
|
$summary = service('enrollmentTransition')->getEnrollmentFinancialSummary(
|
|
$parentId,
|
|
$previousSchoolYear ?? '',
|
|
$selectedYear,
|
|
$this->enrollmentTuitionDue($students)
|
|
);
|
|
$summary['policy_message'] = $this->financialPolicyMessage(
|
|
(string) ($summary['balance_behavior'] ?? 'submission_blocked_until_payment'),
|
|
(string) ($this->schoolYearConfig($selectedYear)['financial_policy_message'] ?? '')
|
|
);
|
|
|
|
return $summary;
|
|
}
|
|
|
|
private function enrollmentTuitionDue(array $students): float
|
|
{
|
|
$tuitionStudents = [];
|
|
$existingTuitionStudentCount = 0;
|
|
|
|
foreach ($students as $student) {
|
|
if ($this->countsTowardCurrentYearTuition($student)) {
|
|
$existingTuitionStudentCount++;
|
|
continue;
|
|
}
|
|
|
|
$evaluation = is_array($student['transition_evaluation'] ?? null) ? $student['transition_evaluation'] : [];
|
|
if (($evaluation['can_enroll'] ?? false) !== true && ($evaluation['parent_enrollment_allowed'] ?? false) !== true) {
|
|
continue;
|
|
}
|
|
|
|
$tuitionStudents[] = [
|
|
'student_id' => (int) ($student['id'] ?? $student['student_id'] ?? 0),
|
|
'class_section_id' => (int) ($evaluation['assigned_class_section_id'] ?? $student['class_section_id'] ?? 0),
|
|
];
|
|
}
|
|
|
|
if ($tuitionStudents === []) {
|
|
return 0.0;
|
|
}
|
|
|
|
if ($existingTuitionStudentCount > 0) {
|
|
return round(count($tuitionStudents) * (float) ($this->configModel->getConfig('second_student_fee') ?? 280), 2);
|
|
}
|
|
|
|
return (new FeeCalculationService())->calculateEnrollmentTuition($tuitionStudents);
|
|
}
|
|
|
|
private function countsTowardCurrentYearTuition(array $student): bool
|
|
{
|
|
$status = strtolower(trim((string) ($student['enrollment_status'] ?? '')));
|
|
if (in_array($status, ['withdrawn', 'withdraw under review', 'refund pending', 'denied', 'not enrolled'], true)) {
|
|
return false;
|
|
}
|
|
|
|
if (in_array($status, ['admission under review', 'review & decision', 'payment pending', 'enrolled', 'waitlist'], true)) {
|
|
return true;
|
|
}
|
|
|
|
return strtolower(trim((string) ($student['admission_status'] ?? ''))) === 'accepted';
|
|
}
|
|
|
|
private function enrollmentFeeSchedule(string $selectedYear): array
|
|
{
|
|
$schoolYearConfig = $this->schoolYearConfig($selectedYear);
|
|
|
|
return [
|
|
'currency' => '$',
|
|
'first_student_fee' => round((float) ($this->configModel->getConfig('first_student_fee') ?? 380), 2),
|
|
'second_student_fee' => round((float) ($this->configModel->getConfig('second_student_fee') ?? 280), 2),
|
|
'tuition_due_at_registration' => round((float) ($schoolYearConfig['tuition_due_at_registration'] ?? 0), 2),
|
|
];
|
|
}
|
|
|
|
private function financialPolicyMessage(string $behavior, string $configured): string
|
|
{
|
|
$configured = trim($configured);
|
|
if ($configured !== '') {
|
|
return $configured;
|
|
}
|
|
|
|
return match ($behavior) {
|
|
'payment_plan_required' => 'Registration may continue under an approved payment plan.',
|
|
'submission_allowed_confirmation_blocked' => 'Registration may be submitted, but it will not be confirmed until the balance is settled.',
|
|
'submission_blocked_until_payment' => 'The balance must be paid before registration can be submitted.',
|
|
'admin_approval_required' => 'Please contact the finance office to arrange an approved exception.',
|
|
default => 'The previous-year balance must be paid before registration can be submitted.',
|
|
};
|
|
}
|
|
|
|
private function ensureStudentYearStatusRows(array $students, string $schoolYear): void
|
|
{
|
|
$schoolYear = trim($schoolYear);
|
|
if ($students === [] || ! preg_match('/^\d{4}-\d{4}$/', $schoolYear)) {
|
|
return;
|
|
}
|
|
|
|
$studentYearStatus = service('studentYearStatus');
|
|
foreach ($students as $student) {
|
|
$studentId = (int) ($student['id'] ?? $student['student_id'] ?? 0);
|
|
if ($studentId <= 0 || $studentYearStatus->hasStatus($studentId, $schoolYear)) {
|
|
continue;
|
|
}
|
|
|
|
$isNew = (int) ($student['is_new'] ?? 1) === 1;
|
|
if (! $studentYearStatus->upsert($studentId, $schoolYear, $isNew)) {
|
|
log_message('error', 'Unable to repair student_year_status for student_id={studentId}, school_year={schoolYear}', [
|
|
'studentId' => $studentId,
|
|
'schoolYear' => $schoolYear,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
private function isReturningReEnrollmentStudent(int $studentId, string $targetSchoolYear): bool
|
|
{
|
|
$previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
|
|
if ($studentId <= 0 || $previousSchoolYear === null) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
foreach ([
|
|
['student_decisions', ['student_id' => $studentId, 'school_year' => $previousSchoolYear]],
|
|
['student_class', ['student_id' => $studentId, 'school_year' => $previousSchoolYear]],
|
|
['enrollments', ['student_id' => $studentId, 'school_year' => $previousSchoolYear]],
|
|
['promotion_queue', ['student_id' => $studentId, 'school_year_from' => $previousSchoolYear, 'school_year_to' => $targetSchoolYear]],
|
|
] as [$table, $where]) {
|
|
if (! $this->db->tableExists($table)) {
|
|
continue;
|
|
}
|
|
|
|
$builder = $this->db->table($table)->select('id');
|
|
foreach ($where as $column => $value) {
|
|
$builder->where($column, $value);
|
|
}
|
|
|
|
if ($builder->limit(1)->get()->getRowArray() !== null) {
|
|
return true;
|
|
}
|
|
}
|
|
} catch (Throwable $e) {
|
|
log_message('error', 'isReturningReEnrollmentStudent failed: ' . $e->getMessage());
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function fallMakeupExamDateForYear(string $schoolYear): ?string
|
|
{
|
|
if ($schoolYear === '' || ! $this->db->tableExists('school_years') || ! $this->db->fieldExists('fall_makeup_exam_on', 'school_years')) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$row = $this->db->table('school_years')
|
|
->select('fall_makeup_exam_on')
|
|
->where('name', $schoolYear)
|
|
->limit(1)
|
|
->get()
|
|
->getRowArray();
|
|
|
|
$date = trim((string) ($row['fall_makeup_exam_on'] ?? ''));
|
|
return $date !== '' ? $date : null;
|
|
} catch (Throwable $e) {
|
|
log_message('error', 'fallMakeupExamDateForYear failed: ' . $e->getMessage());
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private function classSectionNameById(int $classSectionId): ?string
|
|
{
|
|
if ($classSectionId <= 0) {
|
|
return null;
|
|
}
|
|
|
|
$row = $this->db->table('classSection')
|
|
->select('class_section_name')
|
|
->where('class_section_id', $classSectionId)
|
|
->orderBy('id', 'DESC')
|
|
->limit(1)
|
|
->get()
|
|
->getRowArray();
|
|
|
|
$name = trim((string) ($row['class_section_name'] ?? ''));
|
|
return $name !== '' ? $name : null;
|
|
}
|
|
|
|
private function classNameById(int $classId): string
|
|
{
|
|
if ($classId <= 0 || ! $this->db->tableExists('classes')) {
|
|
return 'Assigned grade';
|
|
}
|
|
|
|
$row = $this->db->table('classes')
|
|
->select('class_name')
|
|
->where('id', $classId)
|
|
->limit(1)
|
|
->get()
|
|
->getRowArray();
|
|
|
|
return trim((string) ($row['class_name'] ?? '')) ?: 'Assigned grade';
|
|
}
|
|
|
|
private function gradeLabel(string $className): string
|
|
{
|
|
$className = trim($className);
|
|
if ($className === '') {
|
|
return 'Assigned grade';
|
|
}
|
|
|
|
return preg_match('/^grade\b/i', $className) === 1 ? $className : 'Grade ' . $className;
|
|
}
|
|
|
|
private function schoolYearConfig(string $schoolYear): array
|
|
{
|
|
if ($schoolYear === '' || ! $this->db->tableExists('school_years')) {
|
|
return [];
|
|
}
|
|
|
|
return $this->db->table('school_years')
|
|
->where('name', $schoolYear)
|
|
->limit(1)
|
|
->get()
|
|
->getRowArray() ?: [];
|
|
}
|
|
|
|
private function previousSchoolYearName(string $schoolYear): ?string
|
|
{
|
|
if (! preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches)) {
|
|
return null;
|
|
}
|
|
|
|
return ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1);
|
|
}
|
|
|
|
private function studentNameFromRow(array $student, string $fallback = 'The student'): string
|
|
{
|
|
$name = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? ''));
|
|
|
|
return $name !== '' ? $name : $fallback;
|
|
}
|
|
|
|
private function messageWithStudentName(string $studentName, string $message): string
|
|
{
|
|
$studentName = trim($studentName) !== '' ? trim($studentName) : 'The student';
|
|
$message = trim($message);
|
|
|
|
if ($message === '' || str_contains($message, $studentName)) {
|
|
return $message;
|
|
}
|
|
|
|
return $studentName . ': ' . $message;
|
|
}
|
|
}
|