618 lines
24 KiB
PHP
618 lines
24 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\ConfigurationModel;
|
|
use App\Models\SchoolYearClosingBatchModel;
|
|
use App\Models\SchoolYearModel;
|
|
use App\Models\SchoolYearTransitionLogModel;
|
|
use App\Support\SchoolYear\SchoolYearStatus;
|
|
use CodeIgniter\Database\BaseConnection;
|
|
use DateTimeImmutable;
|
|
use InvalidArgumentException;
|
|
use RuntimeException;
|
|
|
|
final class SchoolYearManagementService
|
|
{
|
|
public function __construct(
|
|
private readonly SchoolYearModel $schoolYearModel,
|
|
private readonly ConfigurationModel $configurationModel,
|
|
private readonly SchoolYearTransitionLogModel $transitionLogModel,
|
|
private readonly SchoolYearClosingBatchModel $closingBatchModel,
|
|
private readonly SchoolYearValidationService $validationService,
|
|
private readonly BaseConnection $db,
|
|
) {
|
|
}
|
|
|
|
public function createDraft(array $payload, ?int $userId = null): int
|
|
{
|
|
$existingYears = $this->db->table($this->schoolYearModel->getTable())->countAllResults();
|
|
$nextDraft = $this->nextDraftDefaults();
|
|
$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 = $this->withCalendarDefaults($payload, (string) $payload['name']);
|
|
$payload = $this->metadataPayload($payload);
|
|
$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;
|
|
|
|
$this->validationService->validateMetadata($payload);
|
|
|
|
$this->db->transStart();
|
|
$id = $this->schoolYearModel->insert($payload, true);
|
|
if ($id !== false) {
|
|
if ($existingYears === 0 || $this->schoolYearModel->active() === null) {
|
|
$this->syncConfigurationFromSchoolYear($payload);
|
|
}
|
|
$this->log((int) $id, null, SchoolYearStatus::DRAFT, 'create', $userId);
|
|
}
|
|
$this->db->transComplete();
|
|
|
|
if ($id === false || $this->db->transStatus() === false) {
|
|
throw new RuntimeException($this->firstModelError('Unable to create school year.'));
|
|
}
|
|
|
|
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();
|
|
$name = $previousYear !== null
|
|
? $this->nextSchoolYearName((string) ($previousYear['name'] ?? ''))
|
|
: null;
|
|
|
|
return [
|
|
'name' => $name,
|
|
'previous_year' => $previousYear,
|
|
];
|
|
}
|
|
|
|
public function updateMetadata(int $id, array $payload, ?int $userId = null): void
|
|
{
|
|
$year = $this->requireYear($id);
|
|
$status = (string) $year['status'];
|
|
|
|
if (SchoolYearStatus::isReadonly($status) || $status === SchoolYearStatus::CLOSING) {
|
|
throw new InvalidArgumentException('This school year is read-only and cannot be edited.');
|
|
}
|
|
|
|
$payload = $this->metadataPayload($payload);
|
|
$payload['updated_by'] = $userId;
|
|
$this->validationService->validateMetadata($payload, $id);
|
|
|
|
$this->db->transStart();
|
|
$updated = $this->schoolYearModel->update($id, $payload);
|
|
if ($updated !== false) {
|
|
if ($status === SchoolYearStatus::ACTIVE) {
|
|
$this->syncConfigurationFromSchoolYear(array_merge($year, $payload));
|
|
}
|
|
$this->log($id, $status, $status, 'metadata_update', $userId);
|
|
}
|
|
$this->db->transComplete();
|
|
|
|
if ($updated === false || $this->db->transStatus() === false) {
|
|
throw new RuntimeException($this->firstModelError('Unable to update school year.'));
|
|
}
|
|
}
|
|
|
|
public function activate(int $id, ?int $userId = null): void
|
|
{
|
|
$year = $this->requireYear($id);
|
|
$from = (string) $year['status'];
|
|
|
|
if (! SchoolYearStatus::canTransition($from, SchoolYearStatus::ACTIVE)) {
|
|
throw new InvalidArgumentException('Only draft or approved reopened school years can be activated.');
|
|
}
|
|
if ($this->configurationTotalInstructionalWeeks() <= 0) {
|
|
throw new InvalidArgumentException('Set total instructional weeks before activating this school year.');
|
|
}
|
|
if ((int) ($year['annual_fee_includes_books'] ?? 1) !== 1) {
|
|
throw new InvalidArgumentException('The withdrawal refund policy requires annual tuition to include books.');
|
|
}
|
|
|
|
$this->db->transStart();
|
|
$activeYears = $this->schoolYearModel->where('status', SchoolYearStatus::ACTIVE)->findAll();
|
|
$now = date('Y-m-d H:i:s');
|
|
|
|
foreach ($activeYears as $activeYear) {
|
|
if ((int) $activeYear['id'] === $id) {
|
|
continue;
|
|
}
|
|
|
|
$this->schoolYearModel->update((int) $activeYear['id'], [
|
|
'status' => SchoolYearStatus::CLOSING,
|
|
'closing_started_at' => $now,
|
|
'updated_by' => $userId,
|
|
]);
|
|
$this->log((int) $activeYear['id'], SchoolYearStatus::ACTIVE, SchoolYearStatus::CLOSING, 'activation_displaced_active_year', $userId, [
|
|
'activated_school_year_id' => $id,
|
|
]);
|
|
}
|
|
|
|
$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']);
|
|
$this->log($id, $from, SchoolYearStatus::ACTIVE, 'activate', $userId);
|
|
|
|
$this->db->transComplete();
|
|
|
|
if ($this->db->transStatus() === false) {
|
|
throw new RuntimeException('Unable to activate school year.');
|
|
}
|
|
}
|
|
|
|
public function syncConfigurationForYear(int $id): void
|
|
{
|
|
$this->syncConfigurationFromSchoolYear($this->requireYear($id));
|
|
}
|
|
|
|
public function deleteDraft(int $id, ?int $userId = null): void
|
|
{
|
|
$year = $this->requireYear($id);
|
|
if (($year['status'] ?? '') !== SchoolYearStatus::DRAFT) {
|
|
throw new InvalidArgumentException('Only unused draft school years can be deleted. Archive historical years instead.');
|
|
}
|
|
|
|
if ($this->hasDependentRecords($id, (string) $year['name'])) {
|
|
throw new InvalidArgumentException('This school year cannot be deleted because related data exists. Archive historical years instead.');
|
|
}
|
|
|
|
$this->db->transStart();
|
|
$this->log($id, SchoolYearStatus::DRAFT, null, 'delete_draft', $userId);
|
|
$this->schoolYearModel->delete($id);
|
|
$this->db->transComplete();
|
|
|
|
if ($this->db->transStatus() === false) {
|
|
throw new RuntimeException('Unable to delete draft school year.');
|
|
}
|
|
}
|
|
|
|
public function archive(int $id, ?int $userId = null): void
|
|
{
|
|
$this->transition($id, SchoolYearStatus::ARCHIVED, 'archive', $userId, [
|
|
'archived_at' => date('Y-m-d H:i:s'),
|
|
]);
|
|
}
|
|
|
|
public function reopen(int $id, string $reason, ?int $userId = null): void
|
|
{
|
|
if (trim($reason) === '') {
|
|
throw new InvalidArgumentException('A reopen reason is required.');
|
|
}
|
|
|
|
$this->transition($id, SchoolYearStatus::ACTIVE, 'reopen', $userId, [
|
|
'metadata' => ['reason' => trim($reason)],
|
|
]);
|
|
}
|
|
|
|
public function latestTransitionByYear(): array
|
|
{
|
|
if (! $this->db->tableExists('school_year_transition_logs')) {
|
|
return [];
|
|
}
|
|
|
|
$rows = $this->transitionLogModel
|
|
->orderBy('created_at', 'DESC')
|
|
->findAll();
|
|
$latest = [];
|
|
|
|
foreach ($rows as $row) {
|
|
$yearId = (int) ($row['school_year_id'] ?? 0);
|
|
if ($yearId > 0 && ! isset($latest[$yearId])) {
|
|
$latest[$yearId] = $row;
|
|
}
|
|
}
|
|
|
|
return $latest;
|
|
}
|
|
|
|
public function log(int $schoolYearId, ?string $from, ?string $to, string $action, ?int $userId = null, array $metadata = []): void
|
|
{
|
|
if (! $this->db->tableExists('school_year_transition_logs')) {
|
|
throw new RuntimeException('School year lifecycle tables are missing. Run database migrations before changing school-year status.');
|
|
}
|
|
|
|
$this->transitionLogModel->insert([
|
|
'school_year_id' => $schoolYearId,
|
|
'from_status' => $from,
|
|
'to_status' => $to,
|
|
'action' => $action,
|
|
'performed_by' => $userId,
|
|
'metadata_json' => $metadata !== [] ? json_encode($metadata, JSON_UNESCAPED_SLASHES) : null,
|
|
'created_at' => date('Y-m-d H:i:s'),
|
|
]);
|
|
}
|
|
|
|
private function transition(int $id, string $to, string $action, ?int $userId, array $options = []): void
|
|
{
|
|
$year = $this->requireYear($id);
|
|
$from = (string) $year['status'];
|
|
|
|
if (! SchoolYearStatus::canTransition($from, $to)) {
|
|
throw new InvalidArgumentException("Cannot transition school year from {$from} to {$to}.");
|
|
}
|
|
|
|
if ($to === SchoolYearStatus::ARCHIVED && ! $this->hasFinalizedClosingBatch($id)) {
|
|
throw new InvalidArgumentException('A school year can be archived only after a finalized closing batch exists.');
|
|
}
|
|
|
|
if ($to === SchoolYearStatus::ACTIVE) {
|
|
$otherActive = $this->schoolYearModel
|
|
->where('status', SchoolYearStatus::ACTIVE)
|
|
->where('id !=', $id)
|
|
->first();
|
|
if ($otherActive !== null) {
|
|
throw new InvalidArgumentException('Another school year is already active. Activate or close years through the controlled lifecycle first.');
|
|
}
|
|
}
|
|
|
|
$this->db->transStart();
|
|
$data = [
|
|
'status' => $to,
|
|
'updated_by' => $userId,
|
|
];
|
|
|
|
foreach (['archived_at', 'closed_at', 'closing_started_at'] as $field) {
|
|
if (isset($options[$field])) {
|
|
$data[$field] = $options[$field];
|
|
}
|
|
}
|
|
|
|
$this->schoolYearModel->update($id, $data);
|
|
$this->log($id, $from, $to, $action, $userId, $options['metadata'] ?? []);
|
|
$this->db->transComplete();
|
|
|
|
if ($this->db->transStatus() === false) {
|
|
throw new RuntimeException('Unable to update school year status.');
|
|
}
|
|
}
|
|
|
|
private function requireYear(int $id): array
|
|
{
|
|
$year = $this->schoolYearModel->find($id);
|
|
if ($year === null) {
|
|
throw new InvalidArgumentException('School year not found.');
|
|
}
|
|
|
|
return $year;
|
|
}
|
|
|
|
private function metadataPayload(array $payload): array
|
|
{
|
|
return [
|
|
'name' => trim((string) ($payload['name'] ?? '')),
|
|
'starts_on' => $this->nullableDate($payload['starts_on'] ?? null),
|
|
'ends_on' => $this->nullableDate($payload['ends_on'] ?? null),
|
|
'description' => trim((string) ($payload['description'] ?? '')) ?: null,
|
|
'registration_starts_on' => $this->nullableDate($payload['registration_starts_on'] ?? null),
|
|
'registration_ends_on' => $this->nullableDate($payload['registration_ends_on'] ?? null),
|
|
'fall_makeup_exam_on' => $this->nullableDate($payload['fall_makeup_exam_on'] ?? null),
|
|
'total_instructional_weeks' => $this->nullableInt($payload['total_instructional_weeks'] ?? null),
|
|
'annual_fee_includes_books' => 1,
|
|
'withdrawal_policy_version' => trim((string) ($payload['withdrawal_policy_version'] ?? 'studied_weeks_v1')) ?: 'studied_weeks_v1',
|
|
'previous_school_year_id' => $this->nullableInt($payload['previous_school_year_id'] ?? null),
|
|
];
|
|
}
|
|
|
|
private function syncConfigurationFromSchoolYear(array $schoolYear): void
|
|
{
|
|
$name = trim((string) ($schoolYear['name'] ?? ''));
|
|
if ($name === '') {
|
|
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,
|
|
'total_instructional_weeks' => (string) ($schoolYear['total_instructional_weeks'] ?? ''),
|
|
'date_age_reference' => $ageReferenceDate,
|
|
'refund_deadline' => $ageReferenceDate,
|
|
'school_year_start_date' => $yearStart,
|
|
'school_year_end_date' => $yearEnd,
|
|
'registration_starts_on' => $registrationDay,
|
|
'enrollment_deadline' => $enrollmentDeadline,
|
|
'first_day_of_school' => $firstDay,
|
|
'Installment_date' => $installment,
|
|
'installment_date' => $installment,
|
|
'fall_semester_start' => $yearStart,
|
|
'school_start_date' => $firstDay,
|
|
'Due_date' => $firstDay,
|
|
'due_date' => $firstDay,
|
|
'last_day_of_school' => $lastDay,
|
|
'Final_Exam_day' => $finalExam,
|
|
'final_exam_day' => $finalExam,
|
|
'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) {
|
|
if (! $this->configurationModel->setConfigValueByKey($key, $value)) {
|
|
throw new RuntimeException("Unable to update configuration value for {$key}.");
|
|
}
|
|
}
|
|
}
|
|
|
|
private function configurationTotalInstructionalWeeks(): int
|
|
{
|
|
$weeks = filter_var($this->configurationModel->getConfig('total_instructional_weeks'), FILTER_VALIDATE_INT);
|
|
|
|
return $weeks !== false && $weeks > 0 ? (int) $weeks : 0;
|
|
}
|
|
|
|
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'),
|
|
'installment_date' => sprintf('%04d-03-01', $endYear),
|
|
'last_day_of_school' => $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)) {
|
|
throw new RuntimeException('Unable to update school-year configuration: invalid school year format.');
|
|
}
|
|
|
|
return $matches[1] . '-12-31';
|
|
}
|
|
|
|
private function previousYearIdForDraft(string $name): ?int
|
|
{
|
|
if (preg_match('/^(\d{4})-(\d{4})$/', $name, $matches)) {
|
|
$previousName = ((int) $matches[1] - 1) . '-' . (int) $matches[1];
|
|
$previousYear = $this->schoolYearModel
|
|
->where('name', $previousName)
|
|
->first();
|
|
|
|
if ($previousYear !== null) {
|
|
return (int) ($previousYear['id'] ?? 0) ?: null;
|
|
}
|
|
}
|
|
|
|
$activeYear = $this->schoolYearModel->active();
|
|
if ($activeYear !== null) {
|
|
return (int) ($activeYear['id'] ?? 0) ?: null;
|
|
}
|
|
|
|
$latestYear = $this->schoolYearModel
|
|
->orderBy('name', 'DESC')
|
|
->first();
|
|
|
|
return $latestYear !== null ? ((int) ($latestYear['id'] ?? 0) ?: null) : null;
|
|
}
|
|
|
|
private function sourceYearForNextDraft(): ?array
|
|
{
|
|
$activeYear = $this->schoolYearModel->active();
|
|
if ($activeYear !== null) {
|
|
return $activeYear;
|
|
}
|
|
|
|
return $this->schoolYearModel
|
|
->orderBy('name', 'DESC')
|
|
->first();
|
|
}
|
|
|
|
private function nextSchoolYearName(string $name): ?string
|
|
{
|
|
if (! preg_match('/^(\d{4})-(\d{4})$/', $name, $matches)) {
|
|
return null;
|
|
}
|
|
|
|
$start = (int) $matches[2];
|
|
|
|
return $start . '-' . ($start + 1);
|
|
}
|
|
|
|
private function nullableDate(mixed $value): ?string
|
|
{
|
|
$value = trim((string) $value);
|
|
|
|
return $value !== '' ? $value : null;
|
|
}
|
|
|
|
private function nullableInt(mixed $value): ?int
|
|
{
|
|
return is_numeric($value) && (int) $value > 0 ? (int) $value : null;
|
|
}
|
|
|
|
private function hasDependentRecords(int $id, string $name): bool
|
|
{
|
|
if (
|
|
$this->db->tableExists('school_year_closing_batches')
|
|
&& $this->closingBatchModel->where('source_school_year_id', $id)->orWhere('target_school_year_id', $id)->first() !== null
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
foreach (['invoices', 'payments', 'student_class', 'teacher_class', 'calendar_events', 'events'] as $table) {
|
|
if ($this->db->tableExists($table) && $this->db->fieldExists('school_year', $table)) {
|
|
$count = $this->db->table($table)->where('school_year', $name)->countAllResults();
|
|
if ($count > 0) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function hasFinalizedClosingBatch(int $sourceSchoolYearId): bool
|
|
{
|
|
if (! $this->db->tableExists('school_year_closing_batches')) {
|
|
return false;
|
|
}
|
|
|
|
return $this->closingBatchModel
|
|
->where('source_school_year_id', $sourceSchoolYearId)
|
|
->whereIn('status', ['completed', 'closed'])
|
|
->first() !== null;
|
|
}
|
|
|
|
private function firstModelError(string $fallback): string
|
|
{
|
|
$errors = $this->schoolYearModel->errors();
|
|
$first = reset($errors);
|
|
|
|
return is_string($first) && $first !== '' ? $first : $fallback;
|
|
}
|
|
|
|
private function syncActiveYearSession(string $schoolYearName): void
|
|
{
|
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
|
return;
|
|
}
|
|
|
|
session()->set('school_year', $schoolYearName);
|
|
session()->remove('selected_school_year_id');
|
|
session()->remove('selected_school_year');
|
|
}
|
|
}
|