This commit is contained in:
@@ -0,0 +1,486 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Support\Enrollment\DeliberationDecision;
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
use DateTimeInterface;
|
||||
|
||||
final class EnrollmentRegistrationEmailService
|
||||
{
|
||||
public const TEMPLATE_VERSION = 'phase5_consolidated_v1';
|
||||
|
||||
public function __construct(
|
||||
private readonly BaseConnection $db,
|
||||
private readonly EnrollmentTransitionService $transitionService,
|
||||
private readonly EmailService $emailService,
|
||||
) {
|
||||
}
|
||||
|
||||
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): 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;
|
||||
}
|
||||
|
||||
foreach ($families as $family) {
|
||||
if ($testEmail === null && ! $force && $this->alreadySent((string) $schoolYear['name'], (int) $family['parent_user_id'])) {
|
||||
$summary['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$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');
|
||||
}
|
||||
}
|
||||
|
||||
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']);
|
||||
|
||||
$examples[] = [
|
||||
'parent_user_id' => (int) $family['parent_user_id'],
|
||||
'parent_name' => (string) $family['name'],
|
||||
'recipients' => $message['recipients'],
|
||||
'student_names' => array_map(
|
||||
static fn (array $student): string => trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')) ?: 'Student #' . (int) ($student['id'] ?? 0),
|
||||
$family['students']
|
||||
),
|
||||
'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): 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);
|
||||
}
|
||||
|
||||
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);
|
||||
$students = [];
|
||||
$studentIds = [];
|
||||
|
||||
foreach ($family['students'] as $student) {
|
||||
$studentId = (int) ($student['id'] ?? 0);
|
||||
if ($studentId <= 0 || $previousYear === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$evaluation = $this->transitionService->evaluate($studentId, $previousYear, $schoolYearName, 'parent');
|
||||
$students[] = $this->studentSection($student, $evaluation, $opens, $deadline);
|
||||
$studentIds[] = $studentId;
|
||||
}
|
||||
|
||||
$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>We are pleased to welcome your family to the registration process for the ' . esc($schoolYearName) . ' school year.</p>'
|
||||
. '<p>Registration opens on ' . esc($opens) . ' and closes on ' . esc($deadline) . '.</p>'
|
||||
. '<p>Please review the information below for each of your children, including the deliberation decision, registration eligibility, expected academic placement, and any required action.</p>'
|
||||
. implode('', $students)
|
||||
. $financial
|
||||
. '<p>To complete registration for each eligible student:</p>'
|
||||
. '<ol><li>Sign in to the parent portal.</li><li>Review and update student information.</li><li>Upload required documents.</li><li>Review and acknowledge school policies.</li><li>Review tuition, fees, and any carry-over balance.</li><li>Submit registration before ' . esc($deadline) . '.</li></ol>'
|
||||
. '<p>Registration portal: <a href="' . esc(site_url('parent/enroll_classes')) . '">' . esc(site_url('parent/enroll_classes')) . '</a></p>'
|
||||
. '<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 studentSection(array $student, array $evaluation, string $opens, string $deadline): 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);
|
||||
$message = $this->decisionMessage($name, $evaluation, $opens, $deadline);
|
||||
|
||||
return '<h3>' . esc($name) . '</h3>'
|
||||
. '<p><strong>Deliberation decision:</strong> ' . esc($decision ?: 'Pending') . '<br>'
|
||||
. '<strong>Registration status:</strong> ' . esc($status) . '<br>'
|
||||
. '<strong>Expected placement:</strong> ' . esc($placement) . '</p>'
|
||||
. '<p>' . esc($message) . '</p>'
|
||||
. '<p><strong>Required action:</strong> ' . esc($requiredAction) . '</p>';
|
||||
}
|
||||
|
||||
private function decisionMessage(string $name, array $evaluation, string $opens, string $deadline): string
|
||||
{
|
||||
if ((string) ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::PASSED) {
|
||||
$grade = $this->currentGradeText($evaluation);
|
||||
|
||||
return 'We are pleased to inform you that ' . $name . ' has successfully passed ' . $grade . '. '
|
||||
. 'Re-enrollment for the new school year will open on ' . $opens . '. Please make sure to re-enroll your child before ' . $deadline . ' to secure their enrollment for the upcoming school year. '
|
||||
. 'To complete the process, sign in to the parent portal, review the student’s information, submit the required documents, acknowledge the school policies, and complete any applicable payment steps.';
|
||||
}
|
||||
|
||||
if (($evaluation['blockers'] ?? []) !== []) {
|
||||
return implode(' ', array_map('strval', $evaluation['blockers']));
|
||||
}
|
||||
|
||||
return match ((string) ($evaluation['deliberation_decision'] ?? '')) {
|
||||
DeliberationDecision::REPEAT_CLASS => 'The deliberation decision for ' . $name . ' is to repeat the current grade. After registration is completed, the student will remain in the same grade and class when available.',
|
||||
DeliberationDecision::MAKE_UP_EXAM => 'The deliberation decision for ' . $name . ' is pending the result of a make-up exam. Registration may be completed now. The student will initially remain in the same grade.',
|
||||
default => 'Please review the registration portal for the current enrollment status.',
|
||||
};
|
||||
}
|
||||
|
||||
private function financialSection(int $parentId, array $schoolYear, ?string $previousYear): string
|
||||
{
|
||||
$carry = $previousYear !== null ? $this->invoiceBalance($parentId, $previousYear) : 0.0;
|
||||
$registrationFee = (float) ($schoolYear['registration_fee'] ?? 0);
|
||||
$tuition = (float) ($schoolYear['tuition_due_at_registration'] ?? 0);
|
||||
$mandatory = (float) ($schoolYear['mandatory_fees'] ?? 0);
|
||||
$total = max(0, $carry) + $registrationFee + $tuition + $mandatory;
|
||||
if ($total <= 0.0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$message = trim((string) ($schoolYear['financial_policy_message'] ?? ''));
|
||||
if ($message === '') {
|
||||
$message = 'The balance is shown for information and does not currently block registration.';
|
||||
}
|
||||
|
||||
return '<h3>Family Account Information</h3>'
|
||||
. '<p><strong>Carry-over balance:</strong> $' . number_format($carry, 2) . '<br>'
|
||||
. '<strong>Registration fee:</strong> $' . number_format($registrationFee, 2) . '<br>'
|
||||
. '<strong>New-year tuition due now:</strong> $' . number_format($tuition, 2) . '<br>'
|
||||
. '<strong>Mandatory fees:</strong> $' . number_format($mandatory, 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;
|
||||
}
|
||||
|
||||
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 ((string) ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::PASSED) {
|
||||
return 'Eligible';
|
||||
}
|
||||
|
||||
if (($evaluation['blockers'] ?? []) !== []) {
|
||||
return ($evaluation['adult_student'] ?? false) ? 'Student Action Required' : 'Not Eligible';
|
||||
}
|
||||
return ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM ? 'Eligible with pending placement' : 'Eligible';
|
||||
}
|
||||
|
||||
private function placementText(array $evaluation): string
|
||||
{
|
||||
return 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 - administrative class assignment required',
|
||||
'exit_required' => 'Completion or exit process required',
|
||||
default => 'Pending',
|
||||
};
|
||||
}
|
||||
|
||||
private function requiredAction(array $evaluation, string $deadline): string
|
||||
{
|
||||
if ((string) ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::PASSED) {
|
||||
return 'Complete re-enrollment before ' . $deadline . '.';
|
||||
}
|
||||
|
||||
if (($evaluation['blockers'] ?? []) !== []) {
|
||||
return ($evaluation['adult_student'] ?? false) ? 'Student must complete the authorized adult-student process or contact administration.' : 'Contact the school administration.';
|
||||
}
|
||||
return ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM
|
||||
? 'Complete re-enrollment and follow the school instructions regarding the make-up exam.'
|
||||
: 'Complete re-enrollment before ' . $deadline . '.';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return preg_match('/^grade\b/i', $grade) === 1 ? $grade : 'Grade ' . $grade;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Support\Enrollment\DeliberationDecision;
|
||||
use App\Support\Enrollment\EnrollmentEligibility;
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
use RuntimeException;
|
||||
|
||||
final class EnrollmentTransitionService
|
||||
{
|
||||
public function __construct(private readonly BaseConnection $db)
|
||||
{
|
||||
}
|
||||
|
||||
public function evaluate(
|
||||
int $studentId,
|
||||
string $sourceSchoolYear,
|
||||
string $targetSchoolYear,
|
||||
string $actorRole = 'parent',
|
||||
?DateTimeInterface $now = null
|
||||
): array {
|
||||
$now = $now !== null ? DateTimeImmutable::createFromInterface($now) : new DateTimeImmutable('now');
|
||||
$targetYear = $this->schoolYearByName($targetSchoolYear);
|
||||
$sourceAssignment = $this->sourceAssignment($studentId, $sourceSchoolYear);
|
||||
$student = $this->student($studentId);
|
||||
$decisionRow = $this->decisionRow($studentId, $sourceSchoolYear);
|
||||
$decision = DeliberationDecision::normalize($decisionRow['deliberation_decision_standard'] ?? null)
|
||||
?? DeliberationDecision::normalize($decisionRow['decision'] ?? null);
|
||||
|
||||
$result = [
|
||||
'student_id' => $studentId,
|
||||
'source_school_year' => $sourceSchoolYear,
|
||||
'target_school_year' => $targetSchoolYear,
|
||||
'deliberation_decision' => $decision,
|
||||
'decision_label' => DeliberationDecision::display($decisionRow['decision'] ?? ''),
|
||||
'source_grade_id' => $sourceAssignment['class_id'] ?? null,
|
||||
'source_grade_name' => $sourceAssignment['class_name'] ?? null,
|
||||
'source_class_section_id' => $sourceAssignment['class_section_id'] ?? null,
|
||||
'source_class_section_name' => $sourceAssignment['class_section_name'] ?? null,
|
||||
'assigned_grade_id' => null,
|
||||
'assigned_class_section_id' => null,
|
||||
'placement_status' => 'not_created',
|
||||
'academic_eligible' => false,
|
||||
'parent_enrollment_allowed' => false,
|
||||
'student_self_enrollment_allowed' => false,
|
||||
'administrative_enrollment_allowed' => true,
|
||||
'age_reference_date' => $this->ageReferenceDate($targetSchoolYear),
|
||||
'age_on_reference_date' => null,
|
||||
'adult_student' => false,
|
||||
'registration_window_status' => 'unknown',
|
||||
'blockers' => [],
|
||||
'warnings' => [],
|
||||
'flags' => [],
|
||||
];
|
||||
|
||||
if ($student === null) {
|
||||
$result['blockers'][] = 'Student record was not found.';
|
||||
return $result;
|
||||
}
|
||||
|
||||
if ($sourceAssignment === null) {
|
||||
$result['blockers'][] = 'Student does not belong to the closing school year.';
|
||||
return $result;
|
||||
}
|
||||
|
||||
if ($decisionRow === null || $decision === null) {
|
||||
$result['blockers'][] = EnrollmentEligibility::MISSING_DECISION_MESSAGE;
|
||||
$result['flags'][] = $this->flag('DEFERRED_DELIBERATION', 'high', [
|
||||
'reason' => 'Missing or unrecognized final deliberation decision.',
|
||||
]);
|
||||
return $result;
|
||||
}
|
||||
|
||||
if ($decision === DeliberationDecision::EXPELLED) {
|
||||
$result['blockers'][] = EnrollmentEligibility::EXPELLED_MESSAGE;
|
||||
$result['flags'][] = $this->flag('RESTRICTED_ADMINISTRATIVE_REVIEW', 'high');
|
||||
return $result;
|
||||
}
|
||||
|
||||
if ($decision === DeliberationDecision::WITHDRAWN) {
|
||||
$result['blockers'][] = EnrollmentEligibility::WITHDRAWN_MESSAGE;
|
||||
$result['flags'][] = $this->flag('WITHDRAWAL_REVIEW_REQUIRED', 'normal');
|
||||
return $result;
|
||||
}
|
||||
|
||||
if ($decision === DeliberationDecision::DEFERRED_DECISION) {
|
||||
$result['blockers'][] = EnrollmentEligibility::DEFERRED_MESSAGE;
|
||||
$result['flags'][] = $this->flag('DEFERRED_DELIBERATION', 'high');
|
||||
return $result;
|
||||
}
|
||||
|
||||
$placement = $this->placement($decision, $sourceAssignment, $targetSchoolYear);
|
||||
$result = array_replace($result, $placement);
|
||||
$result['academic_eligible'] = $placement['placement_status'] !== 'exit_required';
|
||||
|
||||
if ($placement['placement_status'] === 'exit_required') {
|
||||
$result['blockers'][] = 'The student has passed the highest available grade and must follow the school completion or exit process.';
|
||||
$result['flags'][] = $this->flag('COMPLETION_OR_EXIT_PROCESS_REQUIRED', 'normal', [
|
||||
'source_class' => $sourceAssignment['class_section_name'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
$age = EnrollmentEligibility::ageOnSeptemberFirst($student['dob'] ?? null, $targetSchoolYear);
|
||||
$result['age_on_reference_date'] = $age;
|
||||
$result['adult_student'] = $age !== null && $age >= 18;
|
||||
$result['parent_enrollment_allowed'] = $result['academic_eligible'];
|
||||
$result['student_self_enrollment_allowed'] = $result['academic_eligible'] && (bool) ($targetYear['adult_student_registration_enabled'] ?? false);
|
||||
|
||||
if ($result['adult_student']) {
|
||||
$result['parent_enrollment_allowed'] = false;
|
||||
$result['flags'][] = $this->flag('ADULT_STUDENT_ACTION_REQUIRED', 'normal', [
|
||||
'age_on_reference_date' => $age,
|
||||
]);
|
||||
if ($actorRole === 'parent') {
|
||||
$result['blockers'][] = str_replace('The student', $this->studentName($student), EnrollmentEligibility::ADULT_STUDENT_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
$this->applyRegistrationWindow($result, $targetYear, $now, $actorRole);
|
||||
$this->applyAgeRules($result, $targetSchoolYear, $age);
|
||||
|
||||
if ($result['blockers'] === [] && $result['academic_eligible']) {
|
||||
if ($actorRole === 'parent') {
|
||||
$result['parent_enrollment_allowed'] = $result['parent_enrollment_allowed'] && ! $result['adult_student'];
|
||||
}
|
||||
} elseif ($actorRole === 'parent') {
|
||||
$result['parent_enrollment_allowed'] = false;
|
||||
} elseif ($actorRole === 'student') {
|
||||
$result['student_self_enrollment_allowed'] = false;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function applyInitialTransition(
|
||||
int $studentId,
|
||||
string $sourceSchoolYear,
|
||||
string $targetSchoolYear,
|
||||
?int $parentId,
|
||||
?int $performedBy = null,
|
||||
string $actorRole = 'admin'
|
||||
): array {
|
||||
$evaluation = $this->evaluate($studentId, $sourceSchoolYear, $targetSchoolYear, $actorRole);
|
||||
if (! $evaluation['academic_eligible'] || $evaluation['blockers'] !== []) {
|
||||
$this->writeFlags($evaluation, $performedBy);
|
||||
$this->audit($evaluation, 'transition_evaluation_blocked', $performedBy, null, $evaluation);
|
||||
return $evaluation;
|
||||
}
|
||||
|
||||
$this->db->transStart();
|
||||
$original = $this->latestEnrollment($studentId, $targetSchoolYear);
|
||||
$student = $this->student($studentId);
|
||||
$parentId = $parentId ?? (is_numeric($student['parent_id'] ?? null) ? (int) $student['parent_id'] : null);
|
||||
|
||||
$payload = [
|
||||
'student_id' => $studentId,
|
||||
'parent_id' => $parentId,
|
||||
'school_year' => $targetSchoolYear,
|
||||
'source_school_year' => $sourceSchoolYear,
|
||||
'deliberation_decision' => $evaluation['deliberation_decision'],
|
||||
'source_grade_id' => $evaluation['source_grade_id'],
|
||||
'assigned_grade_id' => $evaluation['assigned_grade_id'],
|
||||
'source_class_section_id' => $evaluation['source_class_section_id'],
|
||||
'assigned_class_section_id' => $evaluation['assigned_class_section_id'],
|
||||
'class_section_id' => $evaluation['assigned_class_section_id'],
|
||||
'placement_status' => $evaluation['placement_status'],
|
||||
'age_reference_date' => $evaluation['age_reference_date'],
|
||||
'age_on_reference_date' => $evaluation['age_on_reference_date'],
|
||||
'adult_student' => $evaluation['adult_student'] ? 1 : 0,
|
||||
'parent_enrollment_allowed' => $evaluation['parent_enrollment_allowed'] ? 1 : 0,
|
||||
'student_self_enrollment_allowed' => $evaluation['student_self_enrollment_allowed'] ? 1 : 0,
|
||||
'exception_required' => $evaluation['flags'] !== [] ? 1 : 0,
|
||||
'exception_reason' => $evaluation['flags'] !== [] ? implode(', ', array_column($evaluation['flags'], 'flag_type')) : null,
|
||||
'enrollment_date' => date('Y-m-d'),
|
||||
'enrollment_status' => 'admission under review',
|
||||
'admission_status' => 'pending',
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
if ($original !== null) {
|
||||
$this->db->table('enrollments')->where('id', (int) $original['id'])->update($payload);
|
||||
} else {
|
||||
$payload['created_at'] = date('Y-m-d H:i:s');
|
||||
$this->db->table('enrollments')->insert($payload);
|
||||
}
|
||||
|
||||
if ((int) ($evaluation['assigned_class_section_id'] ?? 0) > 0) {
|
||||
$this->upsertStudentClass($studentId, (int) $evaluation['assigned_class_section_id'], $targetSchoolYear, $performedBy);
|
||||
}
|
||||
|
||||
$this->writeFlags($evaluation, $performedBy);
|
||||
$this->audit($evaluation, 'initial_transition_applied', $performedBy, $original, $payload);
|
||||
$this->db->transComplete();
|
||||
|
||||
if ($this->db->transStatus() === false) {
|
||||
throw new RuntimeException('Unable to apply enrollment transition.');
|
||||
}
|
||||
|
||||
return $evaluation;
|
||||
}
|
||||
|
||||
private function placement(string $decision, array $sourceAssignment, string $targetSchoolYear): array
|
||||
{
|
||||
$sourceClassId = (int) ($sourceAssignment['class_id'] ?? 0);
|
||||
$sourceClassName = trim((string) ($sourceAssignment['class_name'] ?? $sourceAssignment['class_section_name'] ?? ''));
|
||||
$sourceSectionName = trim((string) ($sourceAssignment['class_section_name'] ?? ''));
|
||||
|
||||
if ($decision === DeliberationDecision::PASSED) {
|
||||
$targetClass = $this->nextClass($sourceClassName, $targetSchoolYear);
|
||||
return [
|
||||
'assigned_grade_id' => $targetClass['id'] ?? null,
|
||||
'assigned_grade_name' => $targetClass['class_name'] ?? null,
|
||||
'assigned_class_section_id' => null,
|
||||
'placement_status' => $targetClass === null ? 'exit_required' : 'automatic_distribution_pending',
|
||||
'flags' => [],
|
||||
];
|
||||
}
|
||||
|
||||
if ($decision === DeliberationDecision::REPEAT_CLASS) {
|
||||
$targetSection = $this->matchingSection($sourceSectionName, $sourceClassId, $targetSchoolYear);
|
||||
$flags = [];
|
||||
if ($targetSection === null) {
|
||||
$flags[] = $this->flag('CLASS_REASSIGNMENT_REQUIRED', 'normal', [
|
||||
'previous_class_section_name' => $sourceSectionName,
|
||||
]);
|
||||
} elseif ($this->sectionAtCapacity((int) $targetSection['class_section_id'])) {
|
||||
$flags[] = $this->flag('CLASS_CAPACITY_EXCEPTION_REQUIRED', 'normal', [
|
||||
'class_section_id' => (int) $targetSection['class_section_id'],
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'assigned_grade_id' => $targetSection['class_id'] ?? $this->sameClassInTargetYear($sourceClassName, $targetSchoolYear)['id'] ?? ($sourceClassId ?: null),
|
||||
'assigned_grade_name' => $this->classNameForId((int) ($targetSection['class_id'] ?? 0)) ?? $sourceClassName,
|
||||
'assigned_class_section_id' => $targetSection['class_section_id'] ?? null,
|
||||
'placement_status' => $targetSection === null ? 'manual_class_required' : 'same_class_assigned',
|
||||
'flags' => $flags,
|
||||
];
|
||||
}
|
||||
|
||||
if ($decision === DeliberationDecision::MAKE_UP_EXAM) {
|
||||
$targetSection = $this->matchingSection($sourceSectionName, $sourceClassId, $targetSchoolYear);
|
||||
return [
|
||||
'assigned_grade_id' => $targetSection['class_id'] ?? $this->sameClassInTargetYear($sourceClassName, $targetSchoolYear)['id'] ?? ($sourceClassId ?: null),
|
||||
'assigned_grade_name' => $this->classNameForId((int) ($targetSection['class_id'] ?? 0)) ?? $sourceClassName,
|
||||
'assigned_class_section_id' => $targetSection['class_section_id'] ?? null,
|
||||
'placement_status' => $targetSection === null ? 'temporary_manual_class_required' : 'temporary_same_grade',
|
||||
'flags' => [
|
||||
$this->flag('PENDING_MAKE_UP_EXAM_PROMOTION', 'high', [
|
||||
'current_grade_id' => $sourceClassId ?: null,
|
||||
'expected_promoted_grade_id' => $this->nextClass($sourceClassName, $targetSchoolYear)['id'] ?? null,
|
||||
'current_class_section_id' => $targetSection['class_section_id'] ?? null,
|
||||
]),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return ['assigned_grade_id' => null, 'assigned_class_section_id' => null, 'placement_status' => 'not_created', 'flags' => []];
|
||||
}
|
||||
|
||||
private function applyRegistrationWindow(array &$result, ?array $targetYear, DateTimeImmutable $now, string $actorRole): void
|
||||
{
|
||||
if ($targetYear === null) {
|
||||
$result['registration_window_status'] = 'missing_school_year';
|
||||
$result['blockers'][] = 'Target school year configuration was not found.';
|
||||
return;
|
||||
}
|
||||
|
||||
$opensAt = $this->dateTimeFromYear($targetYear, 'registration_opens_at', 'registration_starts_on', false);
|
||||
$deadlineAt = $this->dateTimeFromYear($targetYear, 'registration_deadline_at', 'registration_ends_on', true);
|
||||
|
||||
if ($opensAt !== null && $now < $opensAt) {
|
||||
$result['registration_window_status'] = 'not_open';
|
||||
$result['blockers'][] = 'Registration for the new school year has not opened yet. Registration will be available starting on ' . $opensAt->format('F j, Y g:i A') . '.';
|
||||
return;
|
||||
}
|
||||
|
||||
if ($deadlineAt !== null && $now > $deadlineAt && (int) ($targetYear['late_registration_blocked'] ?? 1) === 1 && $actorRole !== 'admin') {
|
||||
$result['registration_window_status'] = 'closed';
|
||||
$result['blockers'][] = 'The registration deadline was ' . $deadlineAt->format('F j, Y g:i A') . '. Online registration is no longer available. Please contact the school administration if you believe an exception applies.';
|
||||
$result['flags'][] = $this->flag('LATE_REGISTRATION_EXCEPTION', 'normal');
|
||||
return;
|
||||
}
|
||||
|
||||
$result['registration_window_status'] = 'open';
|
||||
}
|
||||
|
||||
private function applyAgeRules(array &$result, string $targetSchoolYear, ?int $age): void
|
||||
{
|
||||
if ($age === null || ! $this->db->tableExists('enrollment_age_rules')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$builder = $this->db->table('enrollment_age_rules')
|
||||
->where('school_year', $targetSchoolYear)
|
||||
->groupStart()
|
||||
->where('grade_class_id', null)
|
||||
->orWhere('grade_class_id', $result['assigned_grade_id'])
|
||||
->groupEnd();
|
||||
|
||||
foreach ($builder->get()->getResultArray() as $rule) {
|
||||
$min = is_numeric($rule['minimum_age'] ?? null) ? (int) $rule['minimum_age'] : null;
|
||||
$max = is_numeric($rule['maximum_age'] ?? null) ? (int) $rule['maximum_age'] : null;
|
||||
$violated = ($min !== null && $age < $min) || ($max !== null && $age > $max);
|
||||
if (! $violated) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$message = 'Student age does not satisfy a configured age rule for the target placement.';
|
||||
if (($rule['behavior'] ?? 'blocking') === 'warning') {
|
||||
$result['warnings'][] = $message;
|
||||
} else {
|
||||
$result['blockers'][] = $message;
|
||||
$result['flags'][] = $this->flag('AGE_EXCEPTION_REQUIRED', 'normal', [
|
||||
'age_rule_id' => (int) $rule['id'],
|
||||
'age_on_reference_date' => $age,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function sourceAssignment(int $studentId, string $sourceSchoolYear): ?array
|
||||
{
|
||||
if (! $this->db->tableExists('student_class')) {
|
||||
return $this->sourceEnrollmentAssignment($studentId, $sourceSchoolYear);
|
||||
}
|
||||
|
||||
$assignment = $this->db->table('student_class sc')
|
||||
->select('sc.class_section_id, cs.class_section_name, cs.class_id, c.class_name')
|
||||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left')
|
||||
->join('classes c', 'c.id = cs.class_id', 'left')
|
||||
->where('sc.student_id', $studentId)
|
||||
->where('sc.school_year', $sourceSchoolYear)
|
||||
->where('sc.class_section_id IS NOT NULL', null, false)
|
||||
->orderBy('sc.updated_at', 'DESC')
|
||||
->orderBy('sc.id', 'DESC')
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
return $assignment ?: $this->sourceEnrollmentAssignment($studentId, $sourceSchoolYear);
|
||||
}
|
||||
|
||||
private function sourceEnrollmentAssignment(int $studentId, string $sourceSchoolYear): ?array
|
||||
{
|
||||
if (! $this->db->tableExists('enrollments')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->db->table('enrollments e')
|
||||
->select('e.class_section_id, cs.class_section_name, cs.class_id, c.class_name')
|
||||
->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left')
|
||||
->join('classes c', 'c.id = cs.class_id', 'left')
|
||||
->where('e.student_id', $studentId)
|
||||
->where('e.school_year', $sourceSchoolYear)
|
||||
->where('e.class_section_id IS NOT NULL', null, false)
|
||||
->orderBy('e.updated_at', 'DESC')
|
||||
->orderBy('e.id', 'DESC')
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray() ?: null;
|
||||
}
|
||||
|
||||
private function decisionRow(int $studentId, string $sourceSchoolYear): ?array
|
||||
{
|
||||
if (! $this->db->tableExists('student_decisions')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$select = ['decision', 'source', 'notes', 'class_section_name'];
|
||||
if ($this->db->fieldExists('deliberation_decision_standard', 'student_decisions')) {
|
||||
$select[] = 'deliberation_decision_standard';
|
||||
}
|
||||
|
||||
return $this->db->table('student_decisions')
|
||||
->select($select)
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $sourceSchoolYear)
|
||||
->orderBy('updated_at', 'DESC')
|
||||
->orderBy('id', 'DESC')
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray() ?: null;
|
||||
}
|
||||
|
||||
private function student(int $studentId): ?array
|
||||
{
|
||||
return $this->db->table('students')
|
||||
->select('id, firstname, lastname, dob, parent_id')
|
||||
->where('id', $studentId)
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray() ?: null;
|
||||
}
|
||||
|
||||
private function schoolYearByName(string $schoolYear): ?array
|
||||
{
|
||||
if (! $this->db->tableExists('school_years')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->db->table('school_years')->where('name', $schoolYear)->limit(1)->get()->getRowArray() ?: null;
|
||||
}
|
||||
|
||||
private function nextClass(string $sourceClassName, string $targetSchoolYear): ?array
|
||||
{
|
||||
$base = $this->classBaseName($sourceClassName);
|
||||
$target = match (true) {
|
||||
$base === 'KG' || str_contains($base, 'KINDERGARTEN') => '1',
|
||||
ctype_digit($base) => (string) ((int) $base + 1),
|
||||
$base === 'YOUTH' => 'YOUTH',
|
||||
default => '',
|
||||
};
|
||||
|
||||
if ($target === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$targetClass = $this->classByName($target, $targetSchoolYear);
|
||||
if ($targetClass !== null) {
|
||||
return $targetClass;
|
||||
}
|
||||
|
||||
return ctype_digit($base) && (int) $base >= 9
|
||||
? $this->classByName('YOUTH', $targetSchoolYear)
|
||||
: null;
|
||||
}
|
||||
|
||||
private function sameClassInTargetYear(string $sourceClassName, string $targetSchoolYear): ?array
|
||||
{
|
||||
$base = $this->classBaseName($sourceClassName);
|
||||
if ($base === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_contains($base, 'KINDERGARTEN')) {
|
||||
$base = 'KG';
|
||||
}
|
||||
|
||||
return $this->classByName($base, $targetSchoolYear);
|
||||
}
|
||||
|
||||
private function classBaseName(string $className): string
|
||||
{
|
||||
$base = strtoupper(trim((string) preg_replace('/-.+$/', '', $className)));
|
||||
$base = preg_replace('/\b(CLASS|GRADE)\b/i', '', $base) ?? $base;
|
||||
$base = trim(preg_replace('/\s+/', ' ', $base) ?? $base);
|
||||
|
||||
if (str_contains($base, 'KINDERGARTEN')) {
|
||||
return 'KG';
|
||||
}
|
||||
|
||||
if (preg_match('/\d+/', $base, $matches) === 1) {
|
||||
return (string) (int) $matches[0];
|
||||
}
|
||||
|
||||
return $base;
|
||||
}
|
||||
|
||||
private function classByName(string $className, string $schoolYear): ?array
|
||||
{
|
||||
$builder = $this->db->table('classes')->where('UPPER(class_name)', strtoupper($className));
|
||||
if ($this->db->fieldExists('school_year', 'classes')) {
|
||||
$builder->where('school_year', $schoolYear);
|
||||
}
|
||||
|
||||
$row = $builder->orderBy('id', 'DESC')->limit(1)->get()->getRowArray();
|
||||
if ($row !== null || ! $this->db->fieldExists('school_year', 'classes')) {
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
return $this->db->table('classes')
|
||||
->where('UPPER(class_name)', strtoupper($className))
|
||||
->orderBy('id', 'DESC')
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray() ?: null;
|
||||
}
|
||||
|
||||
private function classNameForId(int $classId): ?string
|
||||
{
|
||||
if ($classId <= 0 || ! $this->db->tableExists('classes')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = $this->db->table('classes')
|
||||
->select('class_name')
|
||||
->where('id', $classId)
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
$name = trim((string) ($row['class_name'] ?? ''));
|
||||
|
||||
return $name !== '' ? $name : null;
|
||||
}
|
||||
|
||||
private function matchingSection(string $sourceSectionName, int $sourceClassId, string $targetSchoolYear): ?array
|
||||
{
|
||||
$builder = $this->db->table('classSection')->select('class_section_id, class_id, class_section_name')->orderBy('id', 'DESC');
|
||||
if ($sourceSectionName !== '') {
|
||||
$builder->where('class_section_name', $sourceSectionName);
|
||||
} else {
|
||||
$builder->where('class_id', $sourceClassId)->where("class_section_name NOT LIKE '%-%'", null, false);
|
||||
}
|
||||
|
||||
if ($this->db->fieldExists('school_year', 'classSection')) {
|
||||
$builder->where('school_year', $targetSchoolYear);
|
||||
}
|
||||
|
||||
return $builder->limit(1)->get()->getRowArray() ?: null;
|
||||
}
|
||||
|
||||
private function sectionAtCapacity(int $classSectionId): bool
|
||||
{
|
||||
$section = $this->db->table('classSection cs')
|
||||
->select('cs.class_section_id, c.capacity')
|
||||
->join('classes c', 'c.id = cs.class_id', 'left')
|
||||
->where('cs.class_section_id', $classSectionId)
|
||||
->orderBy('cs.id', 'DESC')
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
$capacity = is_numeric($section['capacity'] ?? null) ? (int) $section['capacity'] : 0;
|
||||
if ($capacity <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$count = $this->db->table('student_class')->where('class_section_id', $classSectionId)->countAllResults();
|
||||
return $count >= $capacity;
|
||||
}
|
||||
|
||||
private function latestEnrollment(int $studentId, string $schoolYear): ?array
|
||||
{
|
||||
if (! $this->db->tableExists('enrollments')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->db->table('enrollments')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('updated_at', 'DESC')
|
||||
->orderBy('id', 'DESC')
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray() ?: null;
|
||||
}
|
||||
|
||||
private function upsertStudentClass(int $studentId, int $classSectionId, string $schoolYear, ?int $performedBy): void
|
||||
{
|
||||
$existing = $this->db->table('student_class')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('id', 'DESC')
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
$payload = [
|
||||
'student_id' => $studentId,
|
||||
'class_section_id' => $classSectionId,
|
||||
'school_year' => $schoolYear,
|
||||
'updated_by' => $performedBy,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
if ($existing !== null) {
|
||||
$this->db->table('student_class')->where('id', (int) $existing['id'])->update($payload);
|
||||
} else {
|
||||
$payload['created_at'] = date('Y-m-d H:i:s');
|
||||
$this->db->table('student_class')->insert($payload);
|
||||
}
|
||||
}
|
||||
|
||||
private function writeFlags(array $evaluation, ?int $performedBy): void
|
||||
{
|
||||
if (! $this->db->tableExists('enrollment_flags')) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($evaluation['flags'] as $flag) {
|
||||
$existing = $this->db->table('enrollment_flags')
|
||||
->where('student_id', (int) $evaluation['student_id'])
|
||||
->where('school_year', (string) $evaluation['target_school_year'])
|
||||
->where('flag_type', (string) $flag['flag_type'])
|
||||
->where('status', 'open')
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
if ($existing !== null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->db->table('enrollment_flags')->insert([
|
||||
'flag_type' => $flag['flag_type'],
|
||||
'student_id' => (int) $evaluation['student_id'],
|
||||
'school_year' => (string) $evaluation['target_school_year'],
|
||||
'source_school_year' => (string) $evaluation['source_school_year'],
|
||||
'status' => 'open',
|
||||
'priority' => $flag['priority'] ?? 'normal',
|
||||
'assigned_to' => $performedBy,
|
||||
'details_json' => json_encode($flag['details'] ?? [], JSON_UNESCAPED_SLASHES),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function audit(array $evaluation, string $action, ?int $performedBy, ?array $original, array $new): void
|
||||
{
|
||||
if (! $this->db->tableExists('enrollment_transition_audits')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->db->table('enrollment_transition_audits')->insert([
|
||||
'student_id' => (int) $evaluation['student_id'],
|
||||
'school_year' => (string) $evaluation['target_school_year'],
|
||||
'source_school_year' => (string) $evaluation['source_school_year'],
|
||||
'action' => $action,
|
||||
'performed_by' => $performedBy,
|
||||
'original_values_json' => $original !== null ? json_encode($original, JSON_UNESCAPED_SLASHES) : null,
|
||||
'new_values_json' => json_encode($new, JSON_UNESCAPED_SLASHES),
|
||||
'reason' => implode(' ', $evaluation['blockers'] ?? []),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function flag(string $type, string $priority, array $details = []): array
|
||||
{
|
||||
return ['flag_type' => $type, 'priority' => $priority, 'details' => $details];
|
||||
}
|
||||
|
||||
private function ageReferenceDate(string $schoolYear): string
|
||||
{
|
||||
return preg_match('/^(\d{4})/', $schoolYear, $matches) ? $matches[1] . '-09-01' : date('Y') . '-09-01';
|
||||
}
|
||||
|
||||
private function dateTimeFromYear(array $year, string $dateTimeField, string $dateField, bool $endOfDay): ?DateTimeImmutable
|
||||
{
|
||||
$value = trim((string) ($year[$dateTimeField] ?? ''));
|
||||
if ($value === '') {
|
||||
$date = trim((string) ($year[$dateField] ?? ''));
|
||||
$value = $date !== '' ? $date . ($endOfDay ? ' 23:59:59' : ' 00:00:00') : '';
|
||||
}
|
||||
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new DateTimeImmutable($value);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function studentName(array $student): string
|
||||
{
|
||||
$name = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? ''));
|
||||
return $name !== '' ? $name : 'The student';
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Models\SchoolYearClosingItemModel;
|
||||
use App\Models\SchoolYearModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\InvoiceModel;
|
||||
use App\Support\Enrollment\DeliberationDecision;
|
||||
use App\Support\SchoolYear\SchoolYearStatus;
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
use InvalidArgumentException;
|
||||
@@ -644,6 +645,7 @@ final class SchoolYearClosingService
|
||||
'auto_kg_pass' => false,
|
||||
'year_score' => null,
|
||||
'decision' => '',
|
||||
'normalized_decision' => null,
|
||||
'source' => 'missing',
|
||||
'notes' => '',
|
||||
'status' => 'missing',
|
||||
@@ -667,6 +669,7 @@ final class SchoolYearClosingService
|
||||
if ($decision !== null) {
|
||||
$student['year_score'] = $decision['year_score'];
|
||||
$student['decision'] = $decision['decision'];
|
||||
$student['normalized_decision'] = $decision['normalized_decision'];
|
||||
$student['source'] = $decision['source'];
|
||||
$student['notes'] = $decision['notes'];
|
||||
$student['status'] = $decision['status'];
|
||||
@@ -676,17 +679,19 @@ final class SchoolYearClosingService
|
||||
}
|
||||
|
||||
if ($decision === null && $this->isKgStudent($student)) {
|
||||
$kgAgeStatus = $this->kgAgeStatusByTargetYearCutoff((string) $student['dob'], $targetSchoolYear);
|
||||
$kgAgeStatus = $this->kgAgeStatusByTargetYearStartCutoff((string) $student['dob'], $targetSchoolYear);
|
||||
if ($kgAgeStatus === 'pass') {
|
||||
$student['decision'] = 'Pass';
|
||||
$student['normalized_decision'] = DeliberationDecision::PASSED;
|
||||
$student['source'] = 'automatic_kg_age';
|
||||
$student['notes'] = 'Auto-pass KG student: age 6 or older by Dec 31 of the next school year start year.';
|
||||
$student['notes'] = 'Auto-pass KG student: age 6 or older by Sep 1 of the next school year.';
|
||||
$student['status'] = 'decided';
|
||||
$student['auto_kg_pass'] = true;
|
||||
} elseif ($kgAgeStatus === 'keep_kg') {
|
||||
$student['decision'] = 'Keep KG';
|
||||
$student['normalized_decision'] = DeliberationDecision::REPEAT_CLASS;
|
||||
$student['source'] = 'automatic_kg_age';
|
||||
$student['notes'] = 'Auto-keep KG student: younger than 6 by Dec 31 of the next school year start year.';
|
||||
$student['notes'] = 'Auto-keep KG student: younger than 6 by Sep 1 of the next school year.';
|
||||
$student['status'] = 'decided';
|
||||
}
|
||||
}
|
||||
@@ -705,7 +710,7 @@ final class SchoolYearClosingService
|
||||
$summary['pending_decision']++;
|
||||
} else {
|
||||
$summary['with_decision']++;
|
||||
if (strcasecmp((string) $student['decision'], 'Pass') === 0) {
|
||||
if (($student['normalized_decision'] ?? null) === DeliberationDecision::PASSED) {
|
||||
$summary['pass']++;
|
||||
if ($queue === null && $student['auto_kg_pass'] !== true) {
|
||||
$summary['missing_queue']++;
|
||||
@@ -745,7 +750,7 @@ final class SchoolYearClosingService
|
||||
return false;
|
||||
}
|
||||
|
||||
private function kgAgeStatusByTargetYearCutoff(string $dob, ?string $targetSchoolYear): string
|
||||
private function kgAgeStatusByTargetYearStartCutoff(string $dob, ?string $targetSchoolYear): string
|
||||
{
|
||||
$dob = trim($dob);
|
||||
if ($dob === '' || $targetSchoolYear === null || ! preg_match('/^(\d{4})-\d{4}$/', $targetSchoolYear, $matches)) {
|
||||
@@ -754,7 +759,7 @@ final class SchoolYearClosingService
|
||||
|
||||
try {
|
||||
$birthDate = new \DateTimeImmutable($dob);
|
||||
$cutoff = new \DateTimeImmutable($matches[1] . '-12-31');
|
||||
$cutoff = new \DateTimeImmutable($matches[1] . '-09-01');
|
||||
} catch (\Throwable) {
|
||||
return '';
|
||||
}
|
||||
@@ -808,11 +813,13 @@ final class SchoolYearClosingService
|
||||
$decision = trim((string) ($row['decision'] ?? ''));
|
||||
$source = trim((string) ($row['source'] ?? ''));
|
||||
$status = $decision === '' || $source === 'pending' ? 'pending' : 'decided';
|
||||
$normalizedDecision = DeliberationDecision::normalize($decision);
|
||||
|
||||
$decisions[$studentId] = [
|
||||
'class_section_name' => (string) ($row['class_section_name'] ?? ''),
|
||||
'year_score' => is_numeric($row['year_score'] ?? null) ? round((float) $row['year_score'], 2) : null,
|
||||
'decision' => $decision,
|
||||
'normalized_decision' => $normalizedDecision,
|
||||
'source' => $source !== '' ? $source : ($status === 'pending' ? 'pending' : 'manual'),
|
||||
'notes' => (string) ($row['notes'] ?? ''),
|
||||
'status' => $status,
|
||||
|
||||
@@ -22,7 +22,7 @@ final class SchoolYearContextService
|
||||
IncomingRequest $request,
|
||||
?int $routeSchoolYearId = null
|
||||
): SchoolYearContext {
|
||||
$requestedId = $routeSchoolYearId ?? $this->normalizeInt($request->getGet('school_year_id'));
|
||||
$requestedId = $routeSchoolYearId ?? $this->requestedSchoolYearId($request);
|
||||
$requestedName = $this->legacyYearName($request);
|
||||
$userId = (int) (session()->get('user_id') ?? 0);
|
||||
|
||||
@@ -118,6 +118,27 @@ final class SchoolYearContextService
|
||||
return $context;
|
||||
}
|
||||
|
||||
public function forYearName(string $yearName): SchoolYearContext
|
||||
{
|
||||
$yearName = trim($yearName);
|
||||
|
||||
if ($yearName === '') {
|
||||
throw new SchoolYearNotFoundException('Selected school year was not found.');
|
||||
}
|
||||
|
||||
$row = $this->schoolYearModel
|
||||
->where('name', $yearName)
|
||||
->first();
|
||||
|
||||
if ($row === null) {
|
||||
throw new SchoolYearNotFoundException('Selected school year was not found.');
|
||||
}
|
||||
|
||||
$userId = (int) (session()->get('user_id') ?? 0);
|
||||
|
||||
return $this->authorizedContext($row, $userId, true);
|
||||
}
|
||||
|
||||
public function clearSelection(): void
|
||||
{
|
||||
session()->remove('selected_school_year_id');
|
||||
@@ -155,6 +176,31 @@ final class SchoolYearContextService
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
private function requestedSchoolYearId(IncomingRequest $request): ?int
|
||||
{
|
||||
$ids = [];
|
||||
|
||||
foreach (['school_year_id', 'schoolYearId', 'year_id'] as $key) {
|
||||
$value = $this->normalizeInt($request->getGet($key));
|
||||
if ($value !== null) {
|
||||
$ids[$key] = $value;
|
||||
}
|
||||
|
||||
if ($this->isWriteRequest($request)) {
|
||||
$value = $this->normalizeInt($request->getPost($key));
|
||||
if ($value !== null) {
|
||||
$ids['post:' . $key] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (count(array_unique($ids)) > 1) {
|
||||
throw new InvalidSchoolYearSelectionException('Conflicting school-year parameters were provided.');
|
||||
}
|
||||
|
||||
return $ids === [] ? null : (int) reset($ids);
|
||||
}
|
||||
|
||||
private function legacyYearName(IncomingRequest $request): string
|
||||
{
|
||||
$names = [];
|
||||
@@ -164,6 +210,13 @@ final class SchoolYearContextService
|
||||
if ($value !== '') {
|
||||
$names[$key] = $value;
|
||||
}
|
||||
|
||||
if ($this->isWriteRequest($request)) {
|
||||
$value = trim((string) ($request->getPost($key) ?? ''));
|
||||
if ($value !== '') {
|
||||
$names['post:' . $key] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (count(array_unique($names)) > 1) {
|
||||
@@ -179,6 +232,11 @@ final class SchoolYearContextService
|
||||
return $names === [] ? '' : (string) reset($names);
|
||||
}
|
||||
|
||||
private function isWriteRequest(IncomingRequest $request): bool
|
||||
{
|
||||
return in_array(strtoupper($request->getMethod()), ['POST', 'PUT', 'PATCH', 'DELETE'], true);
|
||||
}
|
||||
|
||||
private function authorizedContext(array $row, int $userId, bool $explicit): SchoolYearContext
|
||||
{
|
||||
if (! $this->isSelectable($row, $userId)) {
|
||||
|
||||
Reference in New Issue
Block a user