fix enrollment logic, add financial aid, fix class distribution
Tests / PHPUnit (push) Failing after 1m6s
Tests / PHPUnit (push) Failing after 1m6s
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -59,21 +59,15 @@ class FeeCalculationService
|
||||
});
|
||||
|
||||
// Retrieve fee configs
|
||||
$firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 350);
|
||||
$secondStudentFee = (float) ($configModel->getConfig('second_student_fee') ?? 200);
|
||||
$youthFee = (float) ($configModel->getConfig('youth_fee') ?? 200);
|
||||
$firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 380);
|
||||
$secondStudentFee = (float) ($configModel->getConfig('second_student_fee') ?? 280);
|
||||
|
||||
// Assign tuition_fee to all students (before filtering refunds)
|
||||
$regularCount = 0;
|
||||
$studentCount = 0;
|
||||
foreach ($allStudents as &$student) {
|
||||
$gradeLevel = $this->getGradeLevel($student['grade']);
|
||||
|
||||
if ($gradeLevel > 9) {
|
||||
$studentFee = $youthFee;
|
||||
} else {
|
||||
$studentFee = ($regularCount === 0) ? $firstStudentFee : $secondStudentFee;
|
||||
$regularCount++;
|
||||
}
|
||||
$studentFee = ($studentCount === 0) ? $firstStudentFee : $secondStudentFee;
|
||||
$studentCount++;
|
||||
$student['tuition_fee'] = $studentFee;
|
||||
}
|
||||
unset($student);
|
||||
|
||||
@@ -97,7 +91,7 @@ class FeeCalculationService
|
||||
$daysRemaining = $withdrawDateObj->diff($schoolEndDateObj)->days;
|
||||
$weeksRemaining = min($weekOfStudy, max(0, ceil($daysRemaining / 7)));
|
||||
|
||||
//$studentFee = $student['tuition_fee'];
|
||||
$studentFee = (float) ($student['tuition_fee'] ?? 0);
|
||||
$proportionalRefund = ($studentFee / $weekOfStudy) * $weeksRemaining;
|
||||
$refundAmount += $proportionalRefund;
|
||||
|
||||
@@ -146,9 +140,8 @@ class FeeCalculationService
|
||||
$configModel = new ConfigurationModel();
|
||||
$classSectionModel = new \App\Models\ClassSectionModel();
|
||||
|
||||
$firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 350);
|
||||
$secondStudentFee = (float) ($configModel->getConfig('second_student_fee') ?? 200);
|
||||
$youthFee = (float) ($configModel->getConfig('youth_fee') ?? 200);
|
||||
$firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 380);
|
||||
$secondStudentFee = (float) ($configModel->getConfig('second_student_fee') ?? 280);
|
||||
|
||||
// ✅ Pre-fetch and assign grade/class section names before sorting
|
||||
foreach ($students as &$student) {
|
||||
@@ -162,19 +155,13 @@ class FeeCalculationService
|
||||
return $this->compareGrades($a['grade'], $b['grade']);
|
||||
});
|
||||
|
||||
$regularCount = 0;
|
||||
$studentCount = 0;
|
||||
$totalFee = 0;
|
||||
|
||||
// ✅ Calculate fee
|
||||
foreach ($students as $student) {
|
||||
$gradeLevel = $this->getGradeLevel($student['grade']);
|
||||
|
||||
if ($gradeLevel > 9) {
|
||||
$totalFee += $youthFee;
|
||||
} else {
|
||||
$totalFee += ($regularCount === 0) ? $firstStudentFee : $secondStudentFee;
|
||||
$regularCount++;
|
||||
}
|
||||
$totalFee += ($studentCount === 0) ? $firstStudentFee : $secondStudentFee;
|
||||
$studentCount++;
|
||||
}
|
||||
|
||||
return $totalFee;
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Libraries\IssueInvoiceCommand;
|
||||
use App\Libraries\InvoiceIssuanceService;
|
||||
use App\Libraries\InvoiceLedgerService;
|
||||
use App\Libraries\Tuition\GradeLevelParser;
|
||||
use App\Models\ClassSectionModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\DiscountUsageModel;
|
||||
use App\Models\DiscountVoucherModel;
|
||||
use App\Models\EnrollmentModel;
|
||||
use App\Models\EventChargesModel;
|
||||
use App\Models\FinancialAidRequestModel;
|
||||
use App\Models\InvoiceModel;
|
||||
use App\Models\StudentClassModel;
|
||||
use App\Models\UserModel;
|
||||
use DateTime;
|
||||
use DateTimeZone;
|
||||
use RuntimeException;
|
||||
|
||||
final class FinancialAidService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FinancialAidRequestModel $requestModel,
|
||||
private readonly InvoiceModel $invoiceModel,
|
||||
private readonly DiscountVoucherModel $voucherModel,
|
||||
private readonly DiscountUsageModel $usageModel,
|
||||
private readonly InvoiceLedgerService $invoiceLedgerService,
|
||||
private readonly ?InvoiceIssuanceService $invoiceIssuanceService = null,
|
||||
private readonly ?EnrollmentModel $enrollmentModel = null,
|
||||
private readonly ?StudentClassModel $studentClassModel = null,
|
||||
private readonly ?ClassSectionModel $classSectionModel = null,
|
||||
private readonly ?EventChargesModel $eventChargesModel = null,
|
||||
private readonly ?ConfigurationModel $configurationModel = null,
|
||||
private readonly ?UserModel $userModel = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function applyApprovedAmount(array $request, float $amount, int $reviewedBy, string $adminNote = ''): array
|
||||
{
|
||||
if ($amount <= 0) {
|
||||
throw new RuntimeException('Enter a financial aid amount greater than zero.');
|
||||
}
|
||||
|
||||
$parentId = (int) ($request['parent_id'] ?? 0);
|
||||
$schoolYear = (string) ($request['school_year'] ?? '');
|
||||
$invoice = $this->latestInvoice($parentId, $schoolYear);
|
||||
if ($invoice === null) {
|
||||
$invoice = $this->createInvoiceForParentYear($parentId, $schoolYear);
|
||||
}
|
||||
|
||||
$db = $this->requestModel->db;
|
||||
$db->transStart();
|
||||
|
||||
$code = 'FA-' . (int) ($request['id'] ?? 0) . '-' . date('YmdHis');
|
||||
$voucherId = $this->voucherModel->insert([
|
||||
'code' => $code,
|
||||
'discount_type' => 'fixed',
|
||||
'discount_value' => $amount,
|
||||
'max_uses' => 1,
|
||||
'times_used' => 0,
|
||||
'valid_from' => date('Y-m-d'),
|
||||
'valid_until' => date('Y-m-d', strtotime('+1 year')),
|
||||
'school_year' => $schoolYear,
|
||||
'is_active' => 1,
|
||||
'description' => 'Financial aid request #' . (int) ($request['id'] ?? 0),
|
||||
], true);
|
||||
|
||||
if ($voucherId === false) {
|
||||
$db->transRollback();
|
||||
throw new RuntimeException('Unable to create the financial aid voucher.');
|
||||
}
|
||||
|
||||
$now = function_exists('utc_now') ? utc_now() : date('Y-m-d H:i:s');
|
||||
$amountCents = (int) round($amount * 100);
|
||||
$usagePayload = [
|
||||
'voucher_id' => (int) $voucherId,
|
||||
'invoice_id' => (int) $invoice['id'],
|
||||
'parent_id' => $parentId,
|
||||
'discount_amount' => $amount,
|
||||
'description' => 'Financial aid',
|
||||
'school_year' => $schoolYear,
|
||||
'updated_by' => $reviewedBy,
|
||||
'used_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
if ($db->fieldExists('requested_discount_cents', 'discount_usages')) {
|
||||
$usagePayload['requested_discount_cents'] = $amountCents;
|
||||
$usagePayload['eligible_base_cents'] = $amountCents;
|
||||
$usagePayload['eligible_base_before_cents'] = $amountCents;
|
||||
$usagePayload['applied_discount_cents'] = $amountCents;
|
||||
$usagePayload['application_order'] = 1;
|
||||
}
|
||||
|
||||
$usageId = $this->usageModel->insert($usagePayload, true);
|
||||
if ($usageId === false) {
|
||||
$db->transRollback();
|
||||
throw new RuntimeException('Unable to record the financial aid discount.');
|
||||
}
|
||||
|
||||
$this->voucherModel->update((int) $voucherId, ['times_used' => 1, 'is_active' => 0]);
|
||||
$this->invoiceLedgerService->recalculateInvoice((int) $invoice['id']);
|
||||
|
||||
$this->requestModel->update((int) $request['id'], [
|
||||
'status' => 'approved',
|
||||
'admin_amount' => $amount,
|
||||
'admin_note' => $adminNote,
|
||||
'reviewed_by' => $reviewedBy,
|
||||
'reviewed_at' => $now,
|
||||
'invoice_id' => (int) $invoice['id'],
|
||||
'discount_usage_id' => (int) $usageId,
|
||||
'voucher_id' => (int) $voucherId,
|
||||
]);
|
||||
|
||||
$db->transComplete();
|
||||
if ($db->transStatus() === false) {
|
||||
throw new RuntimeException('Unable to apply the financial aid discount.');
|
||||
}
|
||||
|
||||
return $this->requestModel->find((int) $request['id']) ?? $request;
|
||||
}
|
||||
|
||||
private function latestInvoice(int $parentId, string $schoolYear): ?array
|
||||
{
|
||||
if ($parentId <= 0 || $schoolYear === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->invoiceModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('id', 'DESC')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function createInvoiceForParentYear(int $parentId, string $schoolYear): array
|
||||
{
|
||||
if ($parentId <= 0 || $schoolYear === '') {
|
||||
throw new RuntimeException('Cannot create an invoice because the parent or school year is missing.');
|
||||
}
|
||||
|
||||
$enrollmentModel = $this->enrollmentModel ?? new EnrollmentModel();
|
||||
$studentClassModel = $this->studentClassModel ?? new StudentClassModel();
|
||||
$classSectionModel = $this->classSectionModel ?? new ClassSectionModel();
|
||||
$eventChargesModel = $this->eventChargesModel ?? new EventChargesModel();
|
||||
$configurationModel = $this->configurationModel ?? new ConfigurationModel();
|
||||
$userModel = $this->userModel ?? new UserModel();
|
||||
$invoiceIssuanceService = $this->invoiceIssuanceService ?? new InvoiceIssuanceService(
|
||||
$this->requestModel->db,
|
||||
$this->invoiceModel,
|
||||
null,
|
||||
$this->invoiceLedgerService
|
||||
);
|
||||
|
||||
$semester = (string) ($configurationModel->getConfig('semester') ?: '');
|
||||
$enrollments = $enrollmentModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
if ($enrollments === []) {
|
||||
throw new RuntimeException('No enrollment records were found, so an invoice could not be created for this parent.');
|
||||
}
|
||||
|
||||
$registeredKids = [];
|
||||
$withdrawnKids = [];
|
||||
foreach ($enrollments as $enrollment) {
|
||||
$studentData = [
|
||||
'student_id' => (int) ($enrollment['student_id'] ?? 0),
|
||||
'parent_id' => (int) ($enrollment['parent_id'] ?? 0),
|
||||
'class_section_id' => (int) ($enrollment['class_section_id'] ?? 0),
|
||||
'enrollment_status' => (string) ($enrollment['enrollment_status'] ?? ''),
|
||||
'school_year' => (string) ($enrollment['school_year'] ?? ''),
|
||||
'semester' => (string) ($enrollment['semester'] ?? ''),
|
||||
'admission_status' => (string) ($enrollment['admission_status'] ?? ''),
|
||||
'is_withdrawn' => (int) ($enrollment['is_withdrawn'] ?? 0),
|
||||
];
|
||||
|
||||
if (in_array($studentData['enrollment_status'], ['enrolled', 'payment pending'], true)) {
|
||||
$registeredKids[] = $studentData;
|
||||
} elseif (in_array($studentData['enrollment_status'], ['withdrawn', 'refund pending', 'withdraw under review'], true)) {
|
||||
$withdrawnKids[] = $studentData;
|
||||
}
|
||||
}
|
||||
|
||||
$registeredKids = $this->onlyStudentsWithClassAssignment($registeredKids, $studentClassModel, $schoolYear);
|
||||
$withdrawnKids = $this->onlyStudentsWithClassAssignment($withdrawnKids, $studentClassModel, $schoolYear);
|
||||
$tuitionAmount = $this->calculateTuitionAmount($registeredKids, $withdrawnKids, $classSectionModel, $configurationModel);
|
||||
$eventAmount = array_sum(array_map(
|
||||
static fn(array $row): float => (float) ($row['charged'] ?? 0),
|
||||
$eventChargesModel->getChargesWithEventInfo($parentId, $schoolYear)
|
||||
));
|
||||
$totalAmount = $tuitionAmount + $eventAmount;
|
||||
|
||||
if ($totalAmount <= 0) {
|
||||
throw new RuntimeException('Invoice could not be created because this parent has no billable tuition or event charges.');
|
||||
}
|
||||
|
||||
$schoolId = $userModel->getSchoolIdByUserId($parentId);
|
||||
$invoiceNumber = !empty($schoolId)
|
||||
? 'INV-' . $schoolId . '-' . uniqid()
|
||||
: uniqid('INV-');
|
||||
$issueUtc = (new DateTime('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s');
|
||||
$dueUtc = $this->invoiceDueUtc($configurationModel);
|
||||
|
||||
$result = $invoiceIssuanceService->issueInvoice(new IssueInvoiceCommand([
|
||||
'parent_id' => $parentId,
|
||||
'invoice_number' => $invoiceNumber,
|
||||
'total_amount' => $totalAmount,
|
||||
'paid_amount' => 0,
|
||||
'balance' => $totalAmount,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'issue_date' => $issueUtc,
|
||||
'due_date' => $dueUtc,
|
||||
'created_at' => function_exists('utc_now') ? utc_now() : $issueUtc,
|
||||
'updated_at' => function_exists('utc_now') ? utc_now() : $issueUtc,
|
||||
], $tuitionAmount, $eventAmount, [
|
||||
'parent_id' => $parentId,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'registered_student_count' => count($registeredKids),
|
||||
'withdrawn_student_count' => count($withdrawnKids),
|
||||
]));
|
||||
|
||||
$invoice = $this->invoiceModel->find($result->invoiceId);
|
||||
if (!is_array($invoice)) {
|
||||
throw new RuntimeException('Invoice was created but could not be reloaded.');
|
||||
}
|
||||
|
||||
return $invoice;
|
||||
}
|
||||
|
||||
private function onlyStudentsWithClassAssignment(array $students, StudentClassModel $studentClassModel, string $schoolYear): array
|
||||
{
|
||||
return array_values(array_filter($students, static function (array $student) use ($studentClassModel, $schoolYear): bool {
|
||||
$studentId = (int) ($student['student_id'] ?? 0);
|
||||
return $studentId > 0 && $studentClassModel->hasNonEventAssignment($studentId, $schoolYear);
|
||||
}));
|
||||
}
|
||||
|
||||
private function calculateTuitionAmount(
|
||||
array $registeredKids,
|
||||
array $withdrawnKids,
|
||||
ClassSectionModel $classSectionModel,
|
||||
ConfigurationModel $configurationModel
|
||||
): float {
|
||||
$tuitionStudents = $this->isBeforeRefundDeadline($configurationModel)
|
||||
? $registeredKids
|
||||
: array_merge($registeredKids, $withdrawnKids);
|
||||
|
||||
foreach ($tuitionStudents as &$student) {
|
||||
$gradeName = $classSectionModel->getClassSectionNameBySectionId((int) ($student['class_section_id'] ?? 0));
|
||||
$student['grade'] = strtoupper(trim((string) $gradeName));
|
||||
}
|
||||
unset($student);
|
||||
|
||||
usort($tuitionStudents, static fn(array $left, array $right): int => GradeLevelParser::parse($left['grade'] ?? null) <=> GradeLevelParser::parse($right['grade'] ?? null));
|
||||
|
||||
$firstStudentFee = (float) ($configurationModel->getConfig('first_student_fee') ?? 380);
|
||||
$secondStudentFee = (float) ($configurationModel->getConfig('second_student_fee') ?? 280);
|
||||
$total = 0.0;
|
||||
foreach (array_values($tuitionStudents) as $index => $student) {
|
||||
$total += $index === 0 ? $firstStudentFee : $secondStudentFee;
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
private function isBeforeRefundDeadline(ConfigurationModel $configurationModel): bool
|
||||
{
|
||||
try {
|
||||
$refundDeadline = (string) ($configurationModel->getConfig('refund_deadline') ?? '');
|
||||
if ($refundDeadline === '') {
|
||||
return true;
|
||||
}
|
||||
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
||||
$tz = new DateTimeZone($tzName);
|
||||
return new \DateTimeImmutable('today', $tz) <= new \DateTimeImmutable($refundDeadline, $tz);
|
||||
} catch (\Throwable) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private function invoiceDueUtc(ConfigurationModel $configurationModel): ?string
|
||||
{
|
||||
$dueDate = (string) ($configurationModel->getConfig('first_day_of_school') ?: $configurationModel->getConfig('due_date') ?: '');
|
||||
if ($dueDate === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
||||
$dueLocal = new DateTime($dueDate . ' 19:59:59', new DateTimeZone($tzName));
|
||||
$dueLocal->setTimezone(new DateTimeZone('UTC'));
|
||||
return $dueLocal->format('Y-m-d H:i:s');
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ namespace App\Services;
|
||||
use App\Models\SchoolYearClosingBatchModel;
|
||||
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;
|
||||
@@ -19,7 +18,6 @@ final class SchoolYearClosingService
|
||||
private readonly SchoolYearModel $schoolYearModel,
|
||||
private readonly SchoolYearClosingBatchModel $batchModel,
|
||||
private readonly SchoolYearClosingItemModel $itemModel,
|
||||
private readonly ConfigurationModel $configurationModel,
|
||||
private readonly SchoolYearManagementService $managementService,
|
||||
private readonly BaseConnection $db,
|
||||
) {
|
||||
@@ -261,7 +259,7 @@ final class SchoolYearClosingService
|
||||
'next_school_year_id' => $targetYearId,
|
||||
]);
|
||||
$targetName = (string) ($target['name'] ?? '');
|
||||
$this->configurationModel->setConfigValueByKey('school_year', $targetName);
|
||||
$this->managementService->syncConfigurationForYear($targetYearId);
|
||||
$this->syncActiveYearSession($targetName);
|
||||
$this->managementService->log($sourceYearId, SchoolYearStatus::CLOSING, SchoolYearStatus::CLOSED, 'closing_complete', $userId, [
|
||||
'closing_batch_id' => (int) $batch['id'],
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\SchoolYearModel;
|
||||
use App\Models\SchoolYearTransitionLogModel;
|
||||
use App\Support\SchoolYear\SchoolYearStatus;
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
use DateTimeImmutable;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
|
||||
@@ -25,15 +26,29 @@ final class SchoolYearManagementService
|
||||
|
||||
public function createDraft(array $payload, ?int $userId = null): int
|
||||
{
|
||||
$existingYears = $this->db->table($this->schoolYearModel->getTable())->countAllResults();
|
||||
$nextDraft = $this->nextDraftDefaults();
|
||||
if ($nextDraft['name'] === null) {
|
||||
throw new InvalidArgumentException('Create an initial school year before using automatic next-year draft creation.');
|
||||
$requestedName = trim((string) ($payload['name'] ?? ''));
|
||||
|
||||
if ($existingYears === 0) {
|
||||
if (! preg_match('/^\d{4}-\d{4}$/', $requestedName)) {
|
||||
throw new InvalidArgumentException('Enter the first school year as YYYY-YYYY, for example 2025-2026.');
|
||||
}
|
||||
$payload['name'] = $requestedName;
|
||||
} else {
|
||||
if ($nextDraft['name'] === null) {
|
||||
throw new InvalidArgumentException('Create an initial school year before using automatic next-year draft creation.');
|
||||
}
|
||||
$payload['name'] = $nextDraft['name'];
|
||||
}
|
||||
|
||||
$payload['name'] = $nextDraft['name'];
|
||||
$payload = $this->withCalendarDefaults($payload, (string) $payload['name']);
|
||||
$payload = $this->metadataPayload($payload);
|
||||
$payload['previous_school_year_id'] = (int) ($nextDraft['previous_year']['id'] ?? 0) ?: $this->previousYearIdForDraft((string) $payload['name']);
|
||||
$payload['previous_school_year_id'] = $existingYears === 0
|
||||
? null
|
||||
: ((int) ($nextDraft['previous_year']['id'] ?? 0) ?: $this->previousYearIdForDraft((string) $payload['name']));
|
||||
$payload['status'] = SchoolYearStatus::DRAFT;
|
||||
$payload['carry_over_balance_behavior'] = 'submission_blocked_until_payment';
|
||||
$payload['created_by'] = $userId;
|
||||
$payload['updated_by'] = $userId;
|
||||
|
||||
@@ -42,7 +57,9 @@ final class SchoolYearManagementService
|
||||
$this->db->transStart();
|
||||
$id = $this->schoolYearModel->insert($payload, true);
|
||||
if ($id !== false) {
|
||||
$this->syncConfigurationFromSchoolYear($payload);
|
||||
if ($existingYears === 0 || $this->schoolYearModel->active() === null) {
|
||||
$this->syncConfigurationFromSchoolYear($payload);
|
||||
}
|
||||
$this->log((int) $id, null, SchoolYearStatus::DRAFT, 'create', $userId);
|
||||
}
|
||||
$this->db->transComplete();
|
||||
@@ -54,6 +71,49 @@ final class SchoolYearManagementService
|
||||
return (int) $id;
|
||||
}
|
||||
|
||||
public function ensureNextDraftForClosing(int $sourceYearId, ?int $userId = null): array
|
||||
{
|
||||
$source = $this->requireYear($sourceYearId);
|
||||
$sourceName = (string) ($source['name'] ?? '');
|
||||
$nextName = $this->nextSchoolYearName($sourceName);
|
||||
if ($nextName === null) {
|
||||
throw new InvalidArgumentException('Unable to determine the next school year name.');
|
||||
}
|
||||
|
||||
$existing = $this->schoolYearModel
|
||||
->where('name', $nextName)
|
||||
->first();
|
||||
if ($existing !== null) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$payload = $this->metadataPayload($this->withCalendarDefaults([
|
||||
'name' => $nextName,
|
||||
'previous_school_year_id' => $sourceYearId,
|
||||
], $nextName));
|
||||
$payload['status'] = SchoolYearStatus::DRAFT;
|
||||
$payload['carry_over_balance_behavior'] = 'submission_blocked_until_payment';
|
||||
$payload['created_by'] = $userId;
|
||||
$payload['updated_by'] = $userId;
|
||||
|
||||
$this->validationService->validateMetadata($payload);
|
||||
|
||||
$this->db->transStart();
|
||||
$id = $this->schoolYearModel->insert($payload, true);
|
||||
if ($id !== false) {
|
||||
$this->log((int) $id, null, SchoolYearStatus::DRAFT, 'create_for_closing', $userId, [
|
||||
'source_school_year_id' => $sourceYearId,
|
||||
]);
|
||||
}
|
||||
$this->db->transComplete();
|
||||
|
||||
if ($id === false || $this->db->transStatus() === false) {
|
||||
throw new RuntimeException($this->firstModelError('Unable to create next school year.'));
|
||||
}
|
||||
|
||||
return $this->requireYear((int) $id);
|
||||
}
|
||||
|
||||
public function nextDraftDefaults(): array
|
||||
{
|
||||
$previousYear = $this->sourceYearForNextDraft();
|
||||
@@ -83,7 +143,9 @@ final class SchoolYearManagementService
|
||||
$this->db->transStart();
|
||||
$updated = $this->schoolYearModel->update($id, $payload);
|
||||
if ($updated !== false) {
|
||||
$this->syncConfigurationFromSchoolYear($payload);
|
||||
if ($status === SchoolYearStatus::ACTIVE) {
|
||||
$this->syncConfigurationFromSchoolYear(array_merge($year, $payload));
|
||||
}
|
||||
$this->log($id, $status, $status, 'metadata_update', $userId);
|
||||
}
|
||||
$this->db->transComplete();
|
||||
@@ -121,10 +183,13 @@ final class SchoolYearManagementService
|
||||
]);
|
||||
}
|
||||
|
||||
$year['carry_over_balance_behavior'] = trim((string) ($year['carry_over_balance_behavior'] ?? ''))
|
||||
?: 'submission_blocked_until_payment';
|
||||
$this->schoolYearModel->update($id, [
|
||||
'status' => SchoolYearStatus::ACTIVE,
|
||||
'activated_at' => $now,
|
||||
'updated_by' => $userId,
|
||||
'carry_over_balance_behavior' => $year['carry_over_balance_behavior'],
|
||||
]);
|
||||
$this->syncConfigurationFromSchoolYear($year);
|
||||
$this->syncActiveYearSession((string) $year['name']);
|
||||
@@ -137,6 +202,11 @@ final class SchoolYearManagementService
|
||||
}
|
||||
}
|
||||
|
||||
public function syncConfigurationForYear(int $id): void
|
||||
{
|
||||
$this->syncConfigurationFromSchoolYear($this->requireYear($id));
|
||||
}
|
||||
|
||||
public function deleteDraft(int $id, ?int $userId = null): void
|
||||
{
|
||||
$year = $this->requireYear($id);
|
||||
@@ -289,14 +359,54 @@ final class SchoolYearManagementService
|
||||
throw new RuntimeException('Unable to update school-year configuration: school year name is missing.');
|
||||
}
|
||||
|
||||
$calendar = $this->calendarPayloadForSchoolYear($name);
|
||||
$schoolYear = $this->withCalendarDefaults($schoolYear, $name);
|
||||
$ageReferenceDate = $this->ageReferenceDateForSchoolYear($name);
|
||||
$yearStart = (string) ($schoolYear['starts_on'] ?? $calendar['starts_on']);
|
||||
$yearEnd = (string) ($schoolYear['ends_on'] ?? $calendar['ends_on']);
|
||||
$registrationDay = (string) ($schoolYear['registration_starts_on'] ?? $calendar['registration_starts_on']);
|
||||
$enrollmentDeadline = (string) ($schoolYear['registration_ends_on'] ?? $calendar['registration_ends_on']);
|
||||
$firstDay = $calendar['first_day_of_school'];
|
||||
$lastDay = $calendar['last_day_of_school'];
|
||||
$finalExam = $calendar['final_exam_day'];
|
||||
$makeupExam = (string) ($schoolYear['fall_makeup_exam_on'] ?? $calendar['fall_makeup_exam_on']);
|
||||
$orientation = $calendar['orientation_day'];
|
||||
$midterm = $calendar['midterm_exam_day'];
|
||||
$installment = $calendar['installment_date'];
|
||||
$springStart = $calendar['spring_semester_start'];
|
||||
|
||||
$configValues = [
|
||||
'school_year' => $name,
|
||||
'date_age_reference' => $ageReferenceDate,
|
||||
'refund_deadline' => $ageReferenceDate,
|
||||
'enrollment_deadline' => (string) ($schoolYear['registration_ends_on'] ?? ''),
|
||||
'fall_semester_start' => (string) ($schoolYear['starts_on'] ?? ''),
|
||||
'last_day_of_school' => (string) ($schoolYear['ends_on'] ?? ''),
|
||||
'year_start_date' => $yearStart,
|
||||
'year_end_date' => $yearEnd,
|
||||
'school_year_start_date' => $yearStart,
|
||||
'school_year_end_date' => $yearEnd,
|
||||
'registration_day' => $registrationDay,
|
||||
'registration_starts_on' => $registrationDay,
|
||||
'end_of_registration' => $enrollmentDeadline,
|
||||
'enrollment_deadline' => $enrollmentDeadline,
|
||||
'1st_day_of_school' => $firstDay,
|
||||
'first_day_of_school' => $firstDay,
|
||||
'Installment_date' => $installment,
|
||||
'installment_date' => $installment,
|
||||
'fall_semester_start' => $firstDay,
|
||||
'school_start_date' => $firstDay,
|
||||
'Due_date' => $firstDay,
|
||||
'due_date' => $firstDay,
|
||||
'last_day_of_school' => $lastDay,
|
||||
'last_school_day' => $lastDay,
|
||||
'Final_Exam_day' => $finalExam,
|
||||
'final_exam_day' => $finalExam,
|
||||
'Make_up_exam' => $makeupExam,
|
||||
'make_up_exam' => $makeupExam,
|
||||
'makeup_exam_day' => $makeupExam,
|
||||
'Orientation_day' => $orientation,
|
||||
'orientation_day' => $orientation,
|
||||
'Midterm_exam_day' => $midterm,
|
||||
'midterm_exam_day' => $midterm,
|
||||
'spring_semester_start' => $springStart,
|
||||
];
|
||||
|
||||
foreach ($configValues as $key => $value) {
|
||||
@@ -306,6 +416,76 @@ final class SchoolYearManagementService
|
||||
}
|
||||
}
|
||||
|
||||
private function withCalendarDefaults(array $payload, string $schoolYearName): array
|
||||
{
|
||||
$calendar = $this->calendarPayloadForSchoolYear($schoolYearName);
|
||||
foreach ($calendar as $key => $value) {
|
||||
if (! array_key_exists($key, $payload) || trim((string) $payload[$key]) === '') {
|
||||
$payload[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function calendarPayloadForSchoolYear(string $schoolYearName): array
|
||||
{
|
||||
if (! preg_match('/^(\d{4})-(\d{4})$/', $schoolYearName, $matches)) {
|
||||
throw new RuntimeException('Unable to calculate school-year dates: invalid school year format.');
|
||||
}
|
||||
|
||||
$startYear = (int) $matches[1];
|
||||
$endYear = (int) $matches[2];
|
||||
$firstDay = $this->nextToLastSundayOfMonth($startYear, 9);
|
||||
$finalExam = $this->lastMondayOfMonth($endYear, 5)->modify('-8 days');
|
||||
$midterm = $this->nthSundayOfMonth($endYear, 1, 3);
|
||||
|
||||
return [
|
||||
'starts_on' => sprintf('%04d-08-01', $startYear),
|
||||
'ends_on' => sprintf('%04d-07-31', $endYear),
|
||||
'registration_starts_on' => sprintf('%04d-08-01', $startYear),
|
||||
'registration_ends_on' => $firstDay->modify('+15 days')->format('Y-m-d'),
|
||||
'fall_makeup_exam_on' => $firstDay->modify('-1 week')->format('Y-m-d'),
|
||||
'orientation_day' => $firstDay->modify('-2 weeks')->format('Y-m-d'),
|
||||
'first_day_of_school' => $firstDay->format('Y-m-d'),
|
||||
'1st_day_of_school' => $firstDay->format('Y-m-d'),
|
||||
'installment_date' => sprintf('%04d-03-01', $endYear),
|
||||
'last_day_of_school' => $finalExam->modify('+2 weeks')->format('Y-m-d'),
|
||||
'last_school_day' => $finalExam->modify('+2 weeks')->format('Y-m-d'),
|
||||
'final_exam_day' => $finalExam->format('Y-m-d'),
|
||||
'midterm_exam_day' => $midterm->format('Y-m-d'),
|
||||
'spring_semester_start' => $midterm->modify('+1 week')->format('Y-m-d'),
|
||||
];
|
||||
}
|
||||
|
||||
private function nextToLastSundayOfMonth(int $year, int $month): DateTimeImmutable
|
||||
{
|
||||
$lastDay = (new DateTimeImmutable(sprintf('%04d-%02d-01', $year, $month)))
|
||||
->modify('last day of this month');
|
||||
$lastSunday = $lastDay->modify('-' . ((int) $lastDay->format('w')) . ' days');
|
||||
|
||||
return $lastSunday->modify('-1 week');
|
||||
}
|
||||
|
||||
private function nthSundayOfMonth(int $year, int $month, int $nth): DateTimeImmutable
|
||||
{
|
||||
$firstDay = new DateTimeImmutable(sprintf('%04d-%02d-01', $year, $month));
|
||||
$daysUntilSunday = (7 - (int) $firstDay->format('w')) % 7;
|
||||
|
||||
return $firstDay
|
||||
->modify('+' . $daysUntilSunday . ' days')
|
||||
->modify('+' . max(0, $nth - 1) . ' weeks');
|
||||
}
|
||||
|
||||
private function lastMondayOfMonth(int $year, int $month): DateTimeImmutable
|
||||
{
|
||||
$lastDay = (new DateTimeImmutable(sprintf('%04d-%02d-01', $year, $month)))
|
||||
->modify('last day of this month');
|
||||
$daysSinceMonday = ((int) $lastDay->format('N') + 6) % 7;
|
||||
|
||||
return $lastDay->modify('-' . $daysSinceMonday . ' days');
|
||||
}
|
||||
|
||||
private function ageReferenceDateForSchoolYear(string $schoolYearName): string
|
||||
{
|
||||
if (! preg_match('/^(\d{4})-\d{4}$/', $schoolYearName, $matches)) {
|
||||
|
||||
Reference in New Issue
Block a user