@@ -305,6 +305,7 @@ class ParentController extends BaseController
|
||||
// Attach enrollment and class section data to each student
|
||||
foreach ($students as &$student) {
|
||||
$studentId = $student['id'];
|
||||
$student['age'] = $this->calculateAgeAsOfSchoolYearStartYear($student['dob'] ?? null, $selectedYear);
|
||||
|
||||
// Get class section info (can be multiple sections like Grade + Arabic)
|
||||
$classSections = $this->studentClassModel->getClassSectionsByStudentId($studentId, $selectedYear, true);
|
||||
@@ -428,11 +429,14 @@ class ParentController extends BaseController
|
||||
}
|
||||
if ($existingEnrollment) {
|
||||
if ($existingEnrollment['is_withdrawn'] == 1) {
|
||||
$passedPreviousYear = $this->studentPassedPreviousYear((int)$studentId, (string)$this->schoolYear);
|
||||
|
||||
// Reactivate the enrollment if the student was previously withdrawn
|
||||
$this->enrollmentModel->where('id', $existingEnrollment['id'])->update([
|
||||
'is_withdrawn' => 0,
|
||||
'withdrawal_date' => null,
|
||||
'enrollment_status' => 'payment pending',
|
||||
'enrollment_status' => $passedPreviousYear ? 'payment pending' : 'admission under review',
|
||||
'admission_status' => $passedPreviousYear ? 'accepted' : 'pending',
|
||||
'updated_at' => utc_now()
|
||||
]);
|
||||
log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) has been re-enrolled in enrollment ID {$existingEnrollment['id']}.");
|
||||
@@ -442,6 +446,8 @@ class ParentController extends BaseController
|
||||
log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) is already actively enrolled.");
|
||||
}
|
||||
} else {
|
||||
$passedPreviousYear = $this->studentPassedPreviousYear((int)$studentId, (string)$this->schoolYear);
|
||||
|
||||
// If no enrollment record exists, insert a new enrollment record
|
||||
$result = $this->enrollmentModel->insert([
|
||||
'student_id' => $studentId,
|
||||
@@ -450,8 +456,8 @@ class ParentController extends BaseController
|
||||
'semester' => $this->semester,
|
||||
'enrollment_date' => local_date(utc_now(), 'Y-m-d'),
|
||||
'is_withdrawn' => 0,
|
||||
'enrollment_status' => 'admission under review',
|
||||
'admission_status' => 'pending',
|
||||
'enrollment_status' => $passedPreviousYear ? 'payment pending' : 'admission under review',
|
||||
'admission_status' => $passedPreviousYear ? 'accepted' : 'pending',
|
||||
'created_at' => utc_now()
|
||||
]);
|
||||
|
||||
@@ -625,6 +631,61 @@ class ParentController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
private function studentPassedPreviousYear(int $studentId, string $targetSchoolYear): bool
|
||||
{
|
||||
$previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
|
||||
|
||||
if ($studentId <= 0 || $previousSchoolYear === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($this->db->tableExists('promotion_queue')) {
|
||||
$queuedPromotion = $this->db->table('promotion_queue')
|
||||
->select('id')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year_from', $previousSchoolYear)
|
||||
->where('school_year_to', $targetSchoolYear)
|
||||
->whereIn('status', ['queued', 'assigned', 'applied'])
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
if ($queuedPromotion !== null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->db->tableExists('student_decisions')) {
|
||||
$passedDecision = $this->db->table('student_decisions')
|
||||
->select('id')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $previousSchoolYear)
|
||||
->where('LOWER(TRIM(decision)) = ' . $this->db->escape('pass'), null, false)
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
return $passedDecision !== null;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'studentPassedPreviousYear failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function previousSchoolYearName(string $schoolYear): ?string
|
||||
{
|
||||
$schoolYear = trim($schoolYear);
|
||||
|
||||
if (! preg_match('/^(\d{4})-(\d{4})$/', $schoolYear, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1);
|
||||
}
|
||||
|
||||
public function enrollFailure()
|
||||
{
|
||||
echo view('/parent/enroll_failure');
|
||||
@@ -1754,6 +1815,77 @@ $existing = $this->studentModel
|
||||
}
|
||||
}
|
||||
|
||||
private function calculateAgeAsOfToday(?string $dob): ?int
|
||||
{
|
||||
$dob = trim((string) $dob);
|
||||
|
||||
if ($dob === '') {
|
||||
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;
|
||||
}
|
||||
|
||||
$today = new \DateTimeImmutable('today', $timezone);
|
||||
|
||||
if ($birthDate > $today) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $birthDate->diff($today)->y;
|
||||
} catch (\Throwable $e) {
|
||||
log_message('warning', 'Unable to calculate current age from DOB: {message}', [
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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] . '-12-31', $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;
|
||||
}
|
||||
}
|
||||
|
||||
public function parentEventPage()
|
||||
{
|
||||
$schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class CreateStudentSectionDistributionDrafts extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
if ($this->db->tableExists('student_section_distribution_drafts')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->forge->addField([
|
||||
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
|
||||
'student_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
|
||||
'class_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
|
||||
'class_section_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
|
||||
'school_year' => ['type' => 'VARCHAR', 'constraint' => 20],
|
||||
'previous_school_year' => ['type' => 'VARCHAR', 'constraint' => 20, 'null' => true],
|
||||
'previous_final_score' => ['type' => 'DECIMAL', 'constraint' => '6,2', 'null' => true],
|
||||
'score_group' => ['type' => 'VARCHAR', 'constraint' => 20],
|
||||
'status' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => 'pending'],
|
||||
'batch_key' => ['type' => 'VARCHAR', 'constraint' => 64],
|
||||
'created_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||
'applied_at' => ['type' => 'DATETIME', 'null' => true],
|
||||
'created_at' => ['type' => 'DATETIME', 'null' => true],
|
||||
'updated_at' => ['type' => 'DATETIME', 'null' => true],
|
||||
]);
|
||||
|
||||
$this->forge->addKey('id', true);
|
||||
$this->forge->addUniqueKey(['student_id', 'school_year'], 'unique_distribution_draft_student_year');
|
||||
$this->forge->addKey(['class_id', 'school_year', 'status'], false, 'distribution_draft_class_year_status');
|
||||
$this->forge->addKey(['class_section_id', 'school_year'], false, 'distribution_draft_section_year');
|
||||
$this->forge->createTable('student_section_distribution_drafts');
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
$this->forge->dropTable('student_section_distribution_drafts', true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class StudentSectionDistributionDraftModel extends Model
|
||||
{
|
||||
protected $table = 'student_section_distribution_drafts';
|
||||
protected $primaryKey = 'id';
|
||||
protected $returnType = 'array';
|
||||
protected $useAutoIncrement = true;
|
||||
protected $protectFields = true;
|
||||
|
||||
protected $allowedFields = [
|
||||
'student_id',
|
||||
'class_id',
|
||||
'class_section_id',
|
||||
'school_year',
|
||||
'previous_school_year',
|
||||
'previous_final_score',
|
||||
'score_group',
|
||||
'status',
|
||||
'batch_key',
|
||||
'created_by',
|
||||
'applied_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = 'updated_at';
|
||||
}
|
||||
@@ -27,7 +27,6 @@ class UserModel extends Model
|
||||
'school_id',
|
||||
'failed_attempts',
|
||||
'last_failed_at',
|
||||
'semester',
|
||||
'status',
|
||||
'is_suspended',
|
||||
'is_verified',
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\App\Controllers\View;
|
||||
|
||||
use App\Controllers\View\ParentController;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
use ReflectionMethod;
|
||||
|
||||
final class ParentControllerAgeTest extends CIUnitTestCase
|
||||
{
|
||||
public function testEnrollmentAgeUsesSchoolYearStartYearCutoff(): void
|
||||
{
|
||||
$controller = new class extends ParentController {
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
};
|
||||
|
||||
$method = new ReflectionMethod(ParentController::class, 'calculateAgeAsOfSchoolYearStartYear');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$this->assertSame(6, $method->invoke($controller, '2019-09-15', '2025-2026'));
|
||||
$this->assertSame(5, $method->invoke($controller, '2020-01-01', '2025-2026'));
|
||||
}
|
||||
|
||||
public function testEnrollmentAgeRejectsInvalidInputs(): void
|
||||
{
|
||||
$controller = new class extends ParentController {
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
};
|
||||
|
||||
$method = new ReflectionMethod(ParentController::class, 'calculateAgeAsOfSchoolYearStartYear');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$this->assertNull($method->invoke($controller, '', '2025-2026'));
|
||||
$this->assertNull($method->invoke($controller, 'not-a-date', '2025-2026'));
|
||||
$this->assertNull($method->invoke($controller, '2026-01-01', '2025-2026'));
|
||||
$this->assertNull($method->invoke($controller, '2019-09-15', ''));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user