metadataPayload($payload); $payload['status'] = SchoolYearStatus::DRAFT; $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', $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 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) { $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.'); } $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, ]); } $this->schoolYearModel->update($id, [ 'status' => SchoolYearStatus::ACTIVE, 'activated_at' => $now, 'updated_by' => $userId, ]); $this->configurationModel->setConfigValueByKey('school_year', (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 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), 'previous_school_year_id' => $this->nullableInt($payload['previous_school_year_id'] ?? null), ]; } 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; } }