804 lines
32 KiB
PHP
804 lines
32 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\ConfigurationModel;
|
|
use App\Support\Enrollment\DeliberationDecision;
|
|
use App\Support\Enrollment\EnrollmentEligibility;
|
|
use CodeIgniter\Database\BaseConnection;
|
|
use DateTimeInterface;
|
|
|
|
final class EnrollmentRegistrationEmailService
|
|
{
|
|
public const TEMPLATE_VERSION = 'phase5_consolidated_v3';
|
|
private const SEND_DELAY_SECONDS = 2;
|
|
|
|
public function __construct(
|
|
private readonly BaseConnection $db,
|
|
private readonly EnrollmentTransitionService $transitionService,
|
|
private readonly EmailService $emailService,
|
|
private readonly ?ConfigurationModel $configurationModel = null,
|
|
) {
|
|
}
|
|
|
|
public function sendForDate(DateTimeInterface $date, bool $force = false, ?string $testEmail = null, bool $dryRun = false): array
|
|
{
|
|
$summary = [
|
|
'school_years' => 0,
|
|
'recipients' => 0,
|
|
'sent' => 0,
|
|
'failed' => 0,
|
|
'skipped' => 0,
|
|
'dry_run' => $dryRun,
|
|
'messages' => [],
|
|
];
|
|
|
|
foreach ($this->registrationYearsForDate($date, $force) as $schoolYear) {
|
|
$summary['school_years']++;
|
|
if (! $dryRun && empty($schoolYear['registration_launch_approved_at'])) {
|
|
$summary['messages'][] = 'Registration launch is not approved for ' . (string) ($schoolYear['name'] ?? '') . '.';
|
|
$summary['skipped']++;
|
|
continue;
|
|
}
|
|
|
|
$result = $this->sendForSchoolYear($schoolYear, $testEmail, $dryRun, $force);
|
|
foreach (['recipients', 'sent', 'failed', 'skipped'] as $key) {
|
|
$summary[$key] += $result[$key] ?? 0;
|
|
}
|
|
array_push($summary['messages'], ...($result['messages'] ?? []));
|
|
}
|
|
|
|
return $summary;
|
|
}
|
|
|
|
public function sendForSchoolYear(array $schoolYear, ?string $testEmail = null, bool $dryRun = false, bool $force = false, bool $failedOnly = false): array
|
|
{
|
|
$summary = ['recipients' => 0, 'sent' => 0, 'failed' => 0, 'skipped' => 0, 'messages' => []];
|
|
if (! $dryRun && empty($schoolYear['registration_launch_approved_at'])) {
|
|
$summary['messages'][] = 'Registration launch is not approved for ' . (string) ($schoolYear['name'] ?? '') . '.';
|
|
$summary['skipped']++;
|
|
return $summary;
|
|
}
|
|
|
|
$families = $this->recipientFamilies($schoolYear, $testEmail);
|
|
if ($families === []) {
|
|
$summary['messages'][] = 'No recipient families found for ' . (string) ($schoolYear['name'] ?? '') . '.';
|
|
return $summary;
|
|
}
|
|
|
|
$failedParentIds = $failedOnly ? $this->failedParentIdsForSchoolYear((string) $schoolYear['name']) : null;
|
|
if ($failedOnly && $failedParentIds === []) {
|
|
$summary['messages'][] = 'No failed registration emails found for ' . (string) ($schoolYear['name'] ?? '') . '.';
|
|
return $summary;
|
|
}
|
|
|
|
$sentAttempted = false;
|
|
foreach ($families as $family) {
|
|
if ($failedParentIds !== null && ! in_array((int) $family['parent_user_id'], $failedParentIds, true)) {
|
|
$summary['skipped']++;
|
|
continue;
|
|
}
|
|
|
|
if ($failedParentIds === null && $testEmail === null && ! $force && $this->alreadySent((string) $schoolYear['name'], (int) $family['parent_user_id'])) {
|
|
$summary['skipped']++;
|
|
continue;
|
|
}
|
|
|
|
if ($sentAttempted && ! $dryRun) {
|
|
sleep(self::SEND_DELAY_SECONDS);
|
|
}
|
|
|
|
$message = $this->buildMessage($schoolYear, $family);
|
|
if ($testEmail !== null) {
|
|
$message['subject'] = '[TEST] ' . $message['subject'];
|
|
$message['recipients'] = [$testEmail];
|
|
}
|
|
|
|
$summary['recipients']++;
|
|
$recordId = $dryRun || ! $this->db->tableExists('enrollment_email_records') ? null : $this->recordGenerated($schoolYear, $family, $message);
|
|
$sent = $dryRun || $this->emailService->send($message['recipients'][0], $message['subject'], $message['body'], 'general');
|
|
$sent ? $summary['sent']++ : $summary['failed']++;
|
|
|
|
if (! $dryRun && $recordId !== null) {
|
|
$this->recordDelivery($recordId, $sent, $sent ? null : 'Email send failed');
|
|
}
|
|
|
|
$sentAttempted = true;
|
|
}
|
|
|
|
return $summary;
|
|
}
|
|
|
|
public function previewForParent(string $schoolYearName, int $parentId): ?array
|
|
{
|
|
$schoolYear = $this->schoolYearByName($schoolYearName);
|
|
if ($schoolYear === null) {
|
|
return null;
|
|
}
|
|
|
|
foreach ($this->recipientFamilies($schoolYear, null) as $family) {
|
|
if ((int) $family['parent_user_id'] === $parentId) {
|
|
return $this->buildMessage($schoolYear, $family);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public function previewExamplesForSchoolYear(string $schoolYearName): array
|
|
{
|
|
$schoolYear = $this->schoolYearByName($schoolYearName);
|
|
if ($schoolYear === null) {
|
|
return [];
|
|
}
|
|
|
|
$examples = [];
|
|
foreach ($this->recipientFamilies($schoolYear, null) as $family) {
|
|
$message = $this->buildMessage($schoolYear, $family);
|
|
$latest = $this->latestEmailRecord($schoolYearName, (int) $family['parent_user_id']);
|
|
$previousYear = $this->previousSchoolYearName($schoolYearName);
|
|
$studentSummaries = [];
|
|
|
|
foreach ($family['students'] as $student) {
|
|
$studentId = (int) ($student['id'] ?? 0);
|
|
$evaluation = $studentId > 0 && $previousYear !== null
|
|
? $this->transitionService->evaluateForParent(
|
|
(int) $family['parent_user_id'],
|
|
$studentId,
|
|
$previousYear,
|
|
$schoolYearName
|
|
)
|
|
: [];
|
|
$studentSummaries[] = [
|
|
'name' => trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')) ?: 'Student #' . $studentId,
|
|
'adult_student' => ! empty($evaluation['adult_student']),
|
|
'issue_code' => $this->studentIssueCode($evaluation),
|
|
];
|
|
}
|
|
|
|
$examples[] = [
|
|
'parent_user_id' => (int) $family['parent_user_id'],
|
|
'parent_name' => (string) $family['name'],
|
|
'recipients' => $message['recipients'],
|
|
'students' => $studentSummaries,
|
|
'student_names' => array_column($studentSummaries, 'name'),
|
|
'subject' => (string) $message['subject'],
|
|
'delivery_status' => (string) ($latest['delivery_status'] ?? 'not sent'),
|
|
'sent_at' => $latest['sent_at'] ?? null,
|
|
'failure_reason' => $latest['failure_reason'] ?? null,
|
|
];
|
|
}
|
|
|
|
return $examples;
|
|
}
|
|
|
|
public function sendForSchoolYearName(string $schoolYearName, bool $force = false, bool $failedOnly = false): array
|
|
{
|
|
$schoolYear = $this->schoolYearByName($schoolYearName);
|
|
if ($schoolYear === null) {
|
|
return ['recipients' => 0, 'sent' => 0, 'failed' => 0, 'skipped' => 1, 'messages' => ['School year was not found.']];
|
|
}
|
|
|
|
return $this->sendForSchoolYear($schoolYear, null, false, $force, $failedOnly);
|
|
}
|
|
|
|
private function buildMessage(array $schoolYear, array $family): array
|
|
{
|
|
$schoolYearName = (string) ($schoolYear['name'] ?? '');
|
|
$previousYear = $this->previousSchoolYearName($schoolYearName);
|
|
$deadline = $this->dateText($schoolYear['registration_deadline_at'] ?? $schoolYear['registration_ends_on'] ?? null);
|
|
$opens = $this->dateText($schoolYear['registration_opens_at'] ?? $schoolYear['registration_starts_on'] ?? null);
|
|
$studentRows = [];
|
|
$studentIds = [];
|
|
$hasReEnrollmentEligibleStudent = false;
|
|
|
|
foreach ($family['students'] as $student) {
|
|
$studentId = (int) ($student['id'] ?? 0);
|
|
if ($studentId <= 0 || $previousYear === null) {
|
|
continue;
|
|
}
|
|
|
|
$evaluation = $this->transitionService->evaluateForParent(
|
|
(int) $family['parent_user_id'],
|
|
$studentId,
|
|
$previousYear,
|
|
$schoolYearName
|
|
);
|
|
$studentRows[] = $this->studentRow($student, $evaluation, $opens, $deadline, $schoolYear);
|
|
$studentIds[] = $studentId;
|
|
$hasReEnrollmentEligibleStudent = $hasReEnrollmentEligibleStudent || $this->canCompleteParentReEnrollment($evaluation);
|
|
}
|
|
|
|
$financial = $this->financialSection((int) $family['parent_user_id'], $schoolYear, $previousYear);
|
|
$subject = 'Registration for ' . $schoolYearName . ' Is Now Open';
|
|
$bodyHtml = '<p>Dear ' . esc($family['name']) . ',</p>'
|
|
. '<p>Registration for the ' . esc($schoolYearName) . ' school year is now open. Please review the student(s) summary below and complete the portal steps before ' . esc($deadline) . '.</p>'
|
|
. $this->studentsTable($studentRows)
|
|
. $financial
|
|
. $this->registrationStepsSection($opens, $deadline, $hasReEnrollmentEligibleStudent)
|
|
. '<p>For assistance, please contact the school administration.</p>'
|
|
. '<p>Sincerely,<br>Al Rahma Sunday School<br>School Administration</p>';
|
|
|
|
$body = view('emails/_wrap_layout', [
|
|
'title' => $subject,
|
|
'body_html' => $bodyHtml,
|
|
], ['saveData' => true]);
|
|
|
|
return [
|
|
'subject' => $subject,
|
|
'body' => $body,
|
|
'recipients' => $family['recipients'],
|
|
'student_ids' => array_values(array_unique($studentIds)),
|
|
];
|
|
}
|
|
|
|
private function canCompleteParentReEnrollment(array $evaluation): bool
|
|
{
|
|
return (bool) ($evaluation['parent_enrollment_allowed'] ?? $evaluation['can_enroll'] ?? false);
|
|
}
|
|
|
|
private function registrationStepsSection(string $opens, string $deadline, bool $include): string
|
|
{
|
|
if (! $include) {
|
|
return '';
|
|
}
|
|
|
|
return '<h3>Re-enrollment Steps</h3>'
|
|
. '<p>Re-enrollment opens on ' . esc($opens) . ' and closes on ' . esc($deadline) . '. Complete re-enrollment for each eligible student before the deadline to secure their enrollment for the upcoming school year.</p>'
|
|
. '<ol><li>Sign in to the parent portal.</li><li>Review/update student and contact information.</li><li>Acknowledge school policies.</li><li>Review tuition, fees and any carry-over balance.</li><li>Submit Enrollment.</li></ol>'
|
|
. '<p>Registration portal: <a href="' . esc(site_url('parent/enroll_classes')) . '">' . esc(site_url('parent/enroll_classes')) . '</a></p>';
|
|
}
|
|
|
|
private function studentsTable(array $studentRows): string
|
|
{
|
|
if ($studentRows === []) {
|
|
return '<p>No eligible linked students were found for this email.</p>';
|
|
}
|
|
|
|
return '<div style="margin:12px 0 18px;">'
|
|
. implode('', $studentRows)
|
|
. '</div>';
|
|
}
|
|
|
|
private function studentRow(array $student, array $evaluation, string $opens, string $deadline, array $schoolYear = []): string
|
|
{
|
|
$name = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')) ?: 'Student';
|
|
$decision = DeliberationDecision::display((string) ($evaluation['deliberation_decision'] ?? ''));
|
|
$status = $this->registrationStatus($evaluation);
|
|
$placement = $this->placementText($evaluation);
|
|
$requiredAction = $this->requiredAction($evaluation, $deadline, $name);
|
|
$message = $this->decisionMessage($name, $evaluation, $opens, $deadline, $schoolYear);
|
|
$nextStepHtml = $this->decisionMessageHtml($message, (string) ($evaluation['deliberation_decision'] ?? ''));
|
|
if ($this->shouldAppendRequiredAction($message, $requiredAction)) {
|
|
$nextStepHtml .= '<br><strong>' . esc($requiredAction) . '</strong>';
|
|
}
|
|
|
|
return '<table role="presentation" cellpadding="0" cellspacing="0" style="width:100%; border:1px solid #d9e2ec; border-collapse:collapse; margin:0 0 12px; font-size:14px; line-height:1.45;">'
|
|
. '<tbody>'
|
|
. '<tr>'
|
|
. '<td style="background:#f4f8fb; border-bottom:1px solid #d9e2ec; padding:10px 12px; vertical-align:top; word-break:break-word;">'
|
|
. '<strong style="font-size:16px; color:#1f2937;">' . esc($name) . '</strong>'
|
|
. '</td>'
|
|
. '</tr>'
|
|
. $this->studentDetailRow('Decision', esc($decision ?: 'Pending'))
|
|
. $this->studentDetailRow('Registration Status', '<strong>' . esc($status) . '</strong>' . ($placement !== '' ? '<br>' . esc($placement) : ''))
|
|
. $this->studentDetailRow('Next Step', $nextStepHtml)
|
|
. '</tbody></table>';
|
|
}
|
|
|
|
private function studentDetailRow(string $label, string $valueHtml): string
|
|
{
|
|
return '<tr>'
|
|
. '<td style="border-bottom:1px solid #edf2f7; padding:10px 12px; vertical-align:top; word-break:break-word;">'
|
|
. '<div style="font-size:12px; line-height:1.3; color:#64748b; text-transform:uppercase; font-weight:bold; margin:0 0 4px;">' . esc($label) . '</div>'
|
|
. '<div style="color:#1f2937;">' . $valueHtml . '</div>'
|
|
. '</td>'
|
|
. '</tr>';
|
|
}
|
|
|
|
private function decisionMessageHtml(string $message, string $decision): string
|
|
{
|
|
if ($decision !== DeliberationDecision::MAKE_UP_EXAM) {
|
|
return nl2br(esc($message), false);
|
|
}
|
|
|
|
$lines = explode("\n", $message);
|
|
$lastIndex = count($lines) - 1;
|
|
foreach ($lines as $index => $line) {
|
|
$escaped = esc($line);
|
|
$lines[$index] = $index === $lastIndex ? '<strong>' . $escaped . '</strong>' : $escaped;
|
|
}
|
|
|
|
return implode('<br>', $lines);
|
|
}
|
|
|
|
private function decisionMessage(string $name, array $evaluation, string $opens, string $deadline, array $schoolYear = []): string
|
|
{
|
|
if (! empty($evaluation['kg_missing_decision_eligible']) && ! $this->hasNonKgMissingDecisionBlocker($evaluation)) {
|
|
$placement = $this->placementText($evaluation);
|
|
$placementText = $placement !== '' ? ' The student placement for the new school year is: ' . $placement . '.' : '';
|
|
|
|
return 'KG students may complete registration now. ' . $name . ' is eligible for re-enrollment even though no final deliberation decision is recorded.' . $placementText;
|
|
}
|
|
|
|
if (! $this->canCompleteParentReEnrollment($evaluation) && ! empty($evaluation['primary_parent_message'])) {
|
|
return (string) $evaluation['primary_parent_message'];
|
|
}
|
|
|
|
if (($evaluation['blockers'] ?? []) !== [] && ! $this->hasOnlyNonBlockingEmailBlockers($evaluation)) {
|
|
return implode(' ', array_values(array_unique(array_filter(array_map(
|
|
static fn ($blocker): string => trim((string) $blocker),
|
|
$evaluation['blockers']
|
|
)))));
|
|
}
|
|
|
|
return match ((string) ($evaluation['deliberation_decision'] ?? '')) {
|
|
DeliberationDecision::PASSED => $name . ' has successfully passed ' . $this->currentGradeText($evaluation) . '.',
|
|
DeliberationDecision::REPEAT_CLASS => 'The deliberation decision for ' . $name . ' is to repeat the current grade. After re-enrollment is completed, the student will remain in the same grade.',
|
|
DeliberationDecision::MAKE_UP_EXAM => 'The final academic decision for ' . $name . ' is currently pending the result of a make-up exam. This exam is scheduled for ' . $this->makeupExamDateText($schoolYear) . ' from 9:30 AM to 11:00 AM at ISGL.'
|
|
. "\n" . 'The exam result will determine whether the student advances to the next grade or repeats the current class. Failure to attend the make-up exam will automatically result in the student repeating the class, as no further retake opportunities will be available.'
|
|
. "\n" . 'You must re-enroll ' . $name . ' before the make-up exam can be taken.',
|
|
default => 'Please review the registration portal for the current enrollment status.',
|
|
};
|
|
}
|
|
|
|
private function makeupExamDateText(array $schoolYear = []): string
|
|
{
|
|
$raw = trim((string) ($schoolYear['fall_makeup_exam_on'] ?? ''));
|
|
if ($raw === '') {
|
|
$configModel = $this->configurationModel ?? new ConfigurationModel();
|
|
foreach (['make-up-exam', 'make_up_exam', 'makeup_exam_day'] as $key) {
|
|
$raw = trim((string) ($configModel->getConfig($key) ?? ''));
|
|
if ($raw !== '') {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($raw === '') {
|
|
return 'a date to be announced';
|
|
}
|
|
|
|
$timestamp = strtotime($raw);
|
|
if ($timestamp !== false) {
|
|
return date('m-d-Y', $timestamp);
|
|
}
|
|
|
|
return $raw;
|
|
}
|
|
|
|
private function financialSection(int $parentId, array $schoolYear, ?string $previousYear): string
|
|
{
|
|
$schoolYearName = (string) ($schoolYear['name'] ?? '');
|
|
$summary = $this->transitionService->getEnrollmentFinancialSummary(
|
|
$parentId,
|
|
$previousYear ?? '',
|
|
$schoolYearName
|
|
);
|
|
$fallbackTotal = (float) ($schoolYear['registration_fee'] ?? 0)
|
|
+ (float) ($schoolYear['tuition_due_at_registration'] ?? 0)
|
|
+ (float) ($schoolYear['mandatory_fees'] ?? 0);
|
|
$total = (float) ($summary['total_enrollment_due'] ?? $summary['amount_due'] ?? 0);
|
|
if ($total <= 0.0 && $fallbackTotal > 0.0) {
|
|
$total = $fallbackTotal;
|
|
}
|
|
if ($total <= 0.0) {
|
|
return '';
|
|
}
|
|
|
|
$message = trim((string) ($schoolYear['financial_policy_message'] ?? ''));
|
|
if ($message === '') {
|
|
$message = 'The balance needs to be settled with the school before the re-enrollment process can be started.';
|
|
}
|
|
|
|
return '<h3>Family Account Information</h3>'
|
|
. '<p><strong>Carry-over balance:</strong> $' . number_format((float) ($summary['carry_forward_balance'] ?? 0), 2) . '<br>'
|
|
. '<strong>Total currently due:</strong> $' . number_format($total, 2) . '</p>'
|
|
. '<p>' . esc($message) . '</p>';
|
|
}
|
|
|
|
private function recipientFamilies(array $schoolYear, ?string $testEmail): array
|
|
{
|
|
$builder = $this->db->table('users u')
|
|
->select('u.id AS parent_user_id, u.firstname, u.lastname, u.email')
|
|
->where('u.email IS NOT NULL')
|
|
->where('u.email !=', '');
|
|
|
|
if ($this->db->fieldExists('user_type', 'users')) {
|
|
$builder->where('u.user_type', 'primary');
|
|
}
|
|
|
|
if ($testEmail !== null) {
|
|
$builder->limit(1);
|
|
}
|
|
|
|
$families = [];
|
|
foreach ($builder->get()->getResultArray() as $row) {
|
|
$parentId = (int) ($row['parent_user_id'] ?? 0);
|
|
$students = $this->studentsForParent($parentId);
|
|
if ($students === []) {
|
|
continue;
|
|
}
|
|
|
|
$email = $testEmail ?? strtolower(trim((string) ($row['email'] ?? '')));
|
|
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
|
continue;
|
|
}
|
|
|
|
$name = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')) ?: 'Parent or Guardian';
|
|
$families[$parentId] = [
|
|
'parent_user_id' => $parentId,
|
|
'family_account_id' => $parentId,
|
|
'name' => $name,
|
|
'recipients' => [$email],
|
|
'students' => $students,
|
|
];
|
|
}
|
|
|
|
return array_values($families);
|
|
}
|
|
|
|
private function studentsForParent(int $parentId): array
|
|
{
|
|
return $this->db->table('students')
|
|
->select('id, firstname, lastname, dob')
|
|
->where('parent_id', $parentId)
|
|
->orderBy('lastname', 'ASC')
|
|
->orderBy('firstname', 'ASC')
|
|
->get()
|
|
->getResultArray();
|
|
}
|
|
|
|
private function recordGenerated(array $schoolYear, array $family, array $message): int
|
|
{
|
|
$now = date('Y-m-d H:i:s');
|
|
$this->db->table('enrollment_email_records')->insert([
|
|
'school_year' => (string) ($schoolYear['name'] ?? ''),
|
|
'school_year_id' => (int) ($schoolYear['id'] ?? 0) ?: null,
|
|
'family_account_id' => (int) ($family['family_account_id'] ?? 0) ?: null,
|
|
'parent_user_id' => (int) ($family['parent_user_id'] ?? 0) ?: null,
|
|
'recipient_addresses_json' => json_encode($message['recipients']),
|
|
'template_version' => self::TEMPLATE_VERSION,
|
|
'generated_subject' => $message['subject'],
|
|
'generated_body' => $message['body'],
|
|
'student_ids_included_json' => json_encode($message['student_ids']),
|
|
'generated_at' => $now,
|
|
'delivery_status' => 'generated',
|
|
'created_at' => $now,
|
|
'updated_at' => $now,
|
|
]);
|
|
|
|
return (int) $this->db->insertID();
|
|
}
|
|
|
|
private function recordDelivery(int $recordId, bool $sent, ?string $failure): void
|
|
{
|
|
$this->db->table('enrollment_email_records')
|
|
->where('id', $recordId)
|
|
->update([
|
|
'delivery_status' => $sent ? 'sent' : 'failed',
|
|
'sent_at' => $sent ? date('Y-m-d H:i:s') : null,
|
|
'failure_reason' => $failure,
|
|
'retry_count' => $sent ? 0 : 1,
|
|
'updated_at' => date('Y-m-d H:i:s'),
|
|
]);
|
|
}
|
|
|
|
private function alreadySent(string $schoolYear, int $parentId): bool
|
|
{
|
|
if (! $this->db->tableExists('enrollment_email_records')) {
|
|
return false;
|
|
}
|
|
|
|
return $this->db->table('enrollment_email_records')
|
|
->where('school_year', $schoolYear)
|
|
->where('parent_user_id', $parentId)
|
|
->where('delivery_status', 'sent')
|
|
->countAllResults() > 0;
|
|
}
|
|
|
|
/**
|
|
* @return list<int>
|
|
*/
|
|
private function failedParentIdsForSchoolYear(string $schoolYear): array
|
|
{
|
|
if (! $this->db->tableExists('enrollment_email_records')) {
|
|
return [];
|
|
}
|
|
|
|
$rows = $this->db->table('enrollment_email_records')
|
|
->select('parent_user_id, delivery_status')
|
|
->where('school_year', $schoolYear)
|
|
->orderBy('created_at', 'DESC')
|
|
->orderBy('id', 'DESC')
|
|
->get()
|
|
->getResultArray();
|
|
|
|
$latestByParent = [];
|
|
foreach ($rows as $row) {
|
|
$parentId = (int) ($row['parent_user_id'] ?? 0);
|
|
if ($parentId <= 0 || array_key_exists($parentId, $latestByParent)) {
|
|
continue;
|
|
}
|
|
|
|
$latestByParent[$parentId] = (string) ($row['delivery_status'] ?? '');
|
|
}
|
|
|
|
return array_values(array_map(
|
|
'intval',
|
|
array_keys(array_filter($latestByParent, static fn (string $status): bool => $status === 'failed'))
|
|
));
|
|
}
|
|
|
|
private function latestEmailRecord(string $schoolYear, int $parentId): ?array
|
|
{
|
|
if (! $this->db->tableExists('enrollment_email_records')) {
|
|
return null;
|
|
}
|
|
|
|
return $this->db->table('enrollment_email_records')
|
|
->select('delivery_status, sent_at, failure_reason')
|
|
->where('school_year', $schoolYear)
|
|
->where('parent_user_id', $parentId)
|
|
->orderBy('created_at', 'DESC')
|
|
->orderBy('id', 'DESC')
|
|
->limit(1)
|
|
->get()
|
|
->getRowArray() ?: null;
|
|
}
|
|
|
|
private function registrationYearsForDate(DateTimeInterface $date, bool $force): array
|
|
{
|
|
$builder = $this->db->table('school_years')->where('registration_starts_on IS NOT NULL');
|
|
if (! $force) {
|
|
$builder->where('registration_starts_on', $date->format('Y-m-d'));
|
|
}
|
|
|
|
return $builder->orderBy('registration_starts_on', 'ASC')->get()->getResultArray();
|
|
}
|
|
|
|
private function schoolYearByName(string $schoolYear): ?array
|
|
{
|
|
return $this->db->table('school_years')->where('name', $schoolYear)->limit(1)->get()->getRowArray() ?: null;
|
|
}
|
|
|
|
private function invoiceBalance(int $parentId, string $schoolYear): float
|
|
{
|
|
if (! $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 registrationStatus(array $evaluation): string
|
|
{
|
|
if ($this->canCompleteParentReEnrollment($evaluation)) {
|
|
return ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM
|
|
? 'Eligible with pending placement'
|
|
: 'Eligible';
|
|
}
|
|
|
|
if (! empty($evaluation['kg_missing_decision_eligible']) && ! $this->hasNonKgMissingDecisionBlocker($evaluation)) {
|
|
return 'Eligible';
|
|
}
|
|
|
|
if (
|
|
strtoupper(trim((string) ($evaluation['primary_block_reason'] ?? ''))) === 'ALREADY_ENROLLED'
|
|
|| in_array('ALREADY_ENROLLED', array_map('strval', $evaluation['blocking_rule_codes'] ?? []), true)
|
|
|| strtoupper(trim((string) ($evaluation['decision'] ?? ''))) === 'ALREADY_ENROLLED'
|
|
) {
|
|
return EnrollmentEligibility::alreadyEnrolledTitle($evaluation['enrollment_status'] ?? null);
|
|
}
|
|
|
|
if (! empty($evaluation['adult_student'])) {
|
|
return 'Not Eligible';
|
|
}
|
|
|
|
if (($evaluation['blockers'] ?? []) !== [] && ! $this->hasOnlyNonBlockingEmailBlockers($evaluation)) {
|
|
return 'Not Eligible';
|
|
}
|
|
|
|
if (($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::PASSED) {
|
|
return 'Eligible';
|
|
}
|
|
|
|
if (($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM) {
|
|
return 'Eligible with pending placement';
|
|
}
|
|
|
|
if (($evaluation['blockers'] ?? []) !== []) {
|
|
return 'Not Eligible';
|
|
}
|
|
|
|
return 'Not Eligible';
|
|
}
|
|
|
|
private function hasNonKgMissingDecisionBlocker(array $evaluation): bool
|
|
{
|
|
$ignoredCodes = ['NO_FINAL_DECISION', 'UNRECOGNIZED_DECISION'];
|
|
$blockingCodes = array_values(array_filter(array_map(
|
|
static fn ($code): string => strtoupper(trim((string) $code)),
|
|
$evaluation['blocking_rule_codes'] ?? []
|
|
)));
|
|
$reviewCodes = array_values(array_filter(array_map(
|
|
static fn ($code): string => strtoupper(trim((string) $code)),
|
|
$evaluation['review_rule_codes'] ?? []
|
|
)));
|
|
$codes = array_values(array_unique(array_merge($blockingCodes, $reviewCodes)));
|
|
|
|
return array_values(array_diff($codes, $ignoredCodes)) !== [];
|
|
}
|
|
|
|
private function hasOnlyNonBlockingEmailBlockers(array $evaluation): bool
|
|
{
|
|
$blockers = array_values(array_filter(array_map(
|
|
static fn ($blocker): string => strtolower(trim((string) $blocker)),
|
|
$evaluation['blockers'] ?? []
|
|
)));
|
|
if ($blockers === []) {
|
|
return false;
|
|
}
|
|
|
|
foreach ($blockers as $blocker) {
|
|
if (
|
|
str_contains($blocker, 'registration for the new school year has not opened yet')
|
|
|| str_contains($blocker, 'passed the highest available grade')
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private function studentIssueCode(array $evaluation): string
|
|
{
|
|
if (! empty($evaluation['adult_student'])) {
|
|
return 'ADULT_STUDENT_PARENT_BLOCKED';
|
|
}
|
|
|
|
foreach (($evaluation['flags'] ?? []) as $flag) {
|
|
if (! is_array($flag)) {
|
|
continue;
|
|
}
|
|
|
|
$details = is_array($flag['details'] ?? null) ? $flag['details'] : [];
|
|
$ruleCode = strtoupper(trim((string) ($details['rule_code'] ?? '')));
|
|
if ($ruleCode !== '') {
|
|
return $ruleCode;
|
|
}
|
|
|
|
$flagType = strtoupper(trim((string) ($flag['flag_type'] ?? '')));
|
|
if ($flagType !== '') {
|
|
return $flagType;
|
|
}
|
|
}
|
|
|
|
$primaryBlockReason = strtoupper(trim((string) ($evaluation['primary_block_reason'] ?? '')));
|
|
if ($primaryBlockReason !== '') {
|
|
return $primaryBlockReason;
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
private function placementText(array $evaluation): string
|
|
{
|
|
$placement = match ((string) ($evaluation['placement_status'] ?? '')) {
|
|
'automatic_distribution_pending' => $this->assignedGradeText($evaluation),
|
|
'same_class_assigned' => 'Same grade and class',
|
|
'temporary_same_grade' => 'Same grade initially',
|
|
'manual_class_required' => 'Same grade',
|
|
'exit_required' => 'Completion or exit process required',
|
|
default => '',
|
|
};
|
|
|
|
if ($placement !== '') {
|
|
return $placement;
|
|
}
|
|
|
|
return $this->canCompleteParentReEnrollment($evaluation) ? 'Pending' : '';
|
|
}
|
|
|
|
private function requiredAction(array $evaluation, string $deadline, string $name = 'The student'): string
|
|
{
|
|
if ($this->canCompleteParentReEnrollment($evaluation)) {
|
|
return ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM
|
|
? ''
|
|
: 'Complete re-enrollment before ' . $deadline . '.';
|
|
}
|
|
|
|
if (($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM && ($evaluation['blockers'] ?? []) === []) {
|
|
return '';
|
|
}
|
|
|
|
if (($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::PASSED && $this->hasOnlyNonBlockingEmailBlockers($evaluation)) {
|
|
return 'Complete re-enrollment before ' . $deadline . '.';
|
|
}
|
|
|
|
if (! empty($evaluation['primary_parent_message'])) {
|
|
return (string) $evaluation['primary_parent_message'];
|
|
}
|
|
|
|
return 'Contact the school administration.';
|
|
}
|
|
|
|
private function shouldAppendRequiredAction(string $message, string $requiredAction): bool
|
|
{
|
|
$message = trim($message);
|
|
$requiredAction = trim($requiredAction);
|
|
if ($requiredAction === '') {
|
|
return false;
|
|
}
|
|
if ($message === '') {
|
|
return true;
|
|
}
|
|
|
|
$normalizedMessage = preg_replace('/\s+/', ' ', strtolower($message)) ?? $message;
|
|
$normalizedAction = preg_replace('/\s+/', ' ', strtolower($requiredAction)) ?? $requiredAction;
|
|
|
|
return $normalizedMessage !== $normalizedAction
|
|
&& ! str_contains($normalizedMessage, $normalizedAction)
|
|
&& ! str_contains($normalizedAction, $normalizedMessage);
|
|
}
|
|
|
|
private function previousSchoolYearName(string $schoolYear): ?string
|
|
{
|
|
return preg_match('/^(\d{4})-(\d{4})$/', $schoolYear, $m) ? ((int) $m[1] - 1) . '-' . ((int) $m[2] - 1) : null;
|
|
}
|
|
|
|
private function dateText(mixed $value): string
|
|
{
|
|
$value = trim((string) $value);
|
|
if ($value === '') {
|
|
return 'the posted date';
|
|
}
|
|
try {
|
|
return (new \DateTimeImmutable($value))->format('F j, Y');
|
|
} catch (\Throwable) {
|
|
return $value;
|
|
}
|
|
}
|
|
|
|
private function currentGradeText(array $evaluation): string
|
|
{
|
|
return $this->gradeText(
|
|
$evaluation['source_grade_name'] ?? $evaluation['source_class_section_name'] ?? null,
|
|
'the current grade'
|
|
);
|
|
}
|
|
|
|
private function assignedGradeText(array $evaluation): string
|
|
{
|
|
return $this->gradeText(
|
|
$evaluation['assigned_grade_name'] ?? null,
|
|
'Grade to be assigned'
|
|
);
|
|
}
|
|
|
|
private function gradeText(mixed $value, string $fallback): string
|
|
{
|
|
$grade = trim((string) $value);
|
|
|
|
if ($grade === '') {
|
|
return $fallback;
|
|
}
|
|
|
|
if (in_array(strtoupper($grade), ['KG', 'K', 'KINDERGARTEN'], true)) {
|
|
return 'KG';
|
|
}
|
|
|
|
return preg_match('/^grade\b/i', $grade) === 1 ? $grade : 'Grade ' . $grade;
|
|
}
|
|
}
|