314 lines
11 KiB
PHP
314 lines
11 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 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
|
|
{
|
|
$payload = $this->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;
|
|
}
|
|
}
|