Files
alrahma_sunday_school/app/Services/SchoolYearManagementService.php
T
root e06ccc9cc0
Tests / PHPUnit (push) Failing after 40s
ADD SCHOOL YEAR MANAGEMENT
2026-07-12 02:21:39 -04:00

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;
}
}