Files
alrahma_sunday_school_api/app/Services/Promotions/PromotionEnrollmentService.php
T
2026-06-09 00:03:03 -04:00

395 lines
14 KiB
PHP

<?php
namespace App\Services\Promotions;
use App\Models\Configuration;
use App\Models\Enrollment;
use App\Models\Student;
use App\Models\StudentPromotionRecord;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* Implements the parent-side enrollment workflow described in plan
* sections 6, 8, 9 and 12. Tracks the parent's progress through the
* checklist and finalises the promotion + creates the next-year
* enrollment record once everything is complete.
*/
class PromotionEnrollmentService
{
public const ALLOWED_STEPS = [
'info_confirmed',
'documents_uploaded',
'agreement_accepted',
'payment_completed',
];
public function __construct(
private PromotionStatusService $statusService,
private PromotionAuditService $audit
) {}
/**
* Lists actionable promotion records for a parent (the parent
* portal landing page in plan section 12).
*/
public function overviewForParent(int $parentId, ?string $nextSchoolYear = null): array
{
$year = $nextSchoolYear ?: $this->guessNextSchoolYear();
$query = StudentPromotionRecord::query()->forParent($parentId);
if ($year !== null) {
$query->forNextYear($year);
}
$records = $query
->orderByRaw('CASE WHEN promotion_status IN (?, ?, ?) THEN 0 ELSE 1 END', [
StudentPromotionRecord::STATUS_AWAITING_PARENT,
StudentPromotionRecord::STATUS_ELIGIBLE,
StudentPromotionRecord::STATUS_ENROLLMENT_STARTED,
])
->orderBy('promotion_id', 'desc')
->get();
$studentIds = $records->pluck('student_id')->unique()->all();
/** @var EloquentCollection<int,Student> $studentsCollection */
$studentsCollection = ! empty($studentIds)
? Student::query()->whereIn('id', $studentIds)->get()
: new EloquentCollection;
$studentsById = $studentsCollection->keyBy('id');
$payload = $records->map(function (StudentPromotionRecord $record) use ($studentsById) {
return $this->presentRecord($record, $studentsById->get($record->student_id));
})->all();
return [
'records' => $payload,
'next_school_year' => $year,
'message' => $this->parentBannerMessage($records),
];
}
/**
* Mark the start of the enrollment process for a single promotion
* record.
*/
public function startEnrollment(StudentPromotionRecord $record, int $parentId, ?int $userId = null): StudentPromotionRecord
{
$this->assertParentOwns($record, $parentId);
$this->assertActionable($record);
return DB::transaction(function () use ($record, $parentId, $userId) {
if (! $record->enrollment_started_at) {
$record->enrollment_started_at = now();
}
$record->enrollment_status = StudentPromotionRecord::ENROLLMENT_IN_PROGRESS;
$record->parent_id = $record->parent_id ?: $parentId;
$record->updated_by = $userId;
$record->save();
if ($record->promotion_status !== StudentPromotionRecord::STATUS_ENROLLMENT_STARTED) {
$this->statusService->transition(
$record,
StudentPromotionRecord::STATUS_ENROLLMENT_STARTED,
$userId,
'Parent started enrollment'
);
}
$this->audit->logEnrollmentStarted($record, $userId);
return $record->refresh();
});
}
/**
* Update one or more checklist steps. Returns the (possibly
* finalised) record.
*/
public function updateSteps(
StudentPromotionRecord $record,
int $parentId,
array $steps,
?int $userId = null
): StudentPromotionRecord {
$this->assertParentOwns($record, $parentId);
$this->assertActionable($record);
$sanitized = [];
foreach ($steps as $field => $value) {
if (! in_array($field, self::ALLOWED_STEPS, true)) {
continue;
}
$sanitized[$field] = (bool) $value;
}
if (empty($sanitized)) {
return $record;
}
return DB::transaction(function () use ($record, $parentId, $sanitized, $userId) {
$changed = [];
foreach ($sanitized as $field => $value) {
if ((bool) $record->{$field} !== $value) {
$record->{$field} = $value;
$changed[] = $field;
}
}
if (empty($changed)) {
return $record;
}
if (! $record->enrollment_started_at) {
$record->enrollment_started_at = now();
}
$record->enrollment_status = StudentPromotionRecord::ENROLLMENT_IN_PROGRESS;
$record->parent_id = $record->parent_id ?: $parentId;
$record->updated_by = $userId;
$record->save();
foreach ($changed as $field) {
$this->audit->logEnrollmentStepCompleted($record, $field, $userId);
}
if ($record->promotion_status !== StudentPromotionRecord::STATUS_ENROLLMENT_STARTED) {
if (in_array($record->promotion_status, [
StudentPromotionRecord::STATUS_ELIGIBLE,
StudentPromotionRecord::STATUS_AWAITING_PARENT,
], true)) {
$this->statusService->transition(
$record,
StudentPromotionRecord::STATUS_ENROLLMENT_STARTED,
$userId,
'Enrollment progress recorded'
);
}
}
// Auto-finalise once all checklist items are complete.
if ($record->enrollmentChecklistComplete()) {
$record = $this->finalize($record, $userId);
}
return $record;
});
}
/**
* Submit the enrollment, asserting the checklist is complete.
*/
public function submitEnrollment(
StudentPromotionRecord $record,
int $parentId,
?int $userId = null
): StudentPromotionRecord {
$this->assertParentOwns($record, $parentId);
$this->assertActionable($record);
if (! $record->enrollmentChecklistComplete()) {
throw new RuntimeException('Enrollment checklist is incomplete.');
}
return $this->finalize($record, $userId);
}
/**
* Mark expired records (past deadline, still incomplete) as
* "not_enrolled_for_next_year" (plan section 15).
*
* @return array{ expired:int, ids:array<int,int> }
*/
public function markExpiredDeadlines(?\DateTimeInterface $now = null, ?int $userId = null): array
{
$now = $now ?: now();
$records = StudentPromotionRecord::query()
->whereIn('promotion_status', [
StudentPromotionRecord::STATUS_ELIGIBLE,
StudentPromotionRecord::STATUS_AWAITING_PARENT,
StudentPromotionRecord::STATUS_ENROLLMENT_STARTED,
])
->whereNotNull('enrollment_deadline')
->whereDate('enrollment_deadline', '<', $now)
->get();
$ids = [];
foreach ($records as $record) {
DB::transaction(function () use ($record, $userId, &$ids) {
$record->enrollment_status = StudentPromotionRecord::ENROLLMENT_EXPIRED;
$record->save();
$this->audit->logDeadlineExpired($record);
$this->statusService->forceStatus(
$record,
StudentPromotionRecord::STATUS_NOT_ENROLLED,
$userId,
'Deadline expired without enrollment'
);
$ids[] = (int) $record->getKey();
});
}
return [
'expired' => count($ids),
'ids' => $ids,
];
}
/**
* Finalise promotion + create the next-year enrollment record
* (plan sections 5, 6 step 6 and section 9).
*/
private function finalize(StudentPromotionRecord $record, ?int $userId): StudentPromotionRecord
{
if ($record->promotion_status === StudentPromotionRecord::STATUS_PROMOTED_AND_ENROLLED) {
return $record;
}
return DB::transaction(function () use ($record, $userId) {
$record->enrollment_status = StudentPromotionRecord::ENROLLMENT_COMPLETED;
$record->enrollment_completed_at = $record->enrollment_completed_at ?: now();
$record->promotion_finalized_at = now();
$record->updated_by = $userId;
$enrollmentId = $this->ensureNextYearEnrollment($record);
if ($enrollmentId !== null) {
$record->enrollment_id = $enrollmentId;
}
$record->save();
$this->audit->logEnrollmentCompleted($record, $userId);
$this->statusService->transition(
$record,
StudentPromotionRecord::STATUS_PROMOTED_AND_ENROLLED,
$userId,
'Enrollment checklist complete'
);
$this->audit->logPromotionFinalized($record->refresh(), $userId);
return $record->refresh();
});
}
/**
* Create or update an `enrollments` row for the next school year so
* the student is officially placed in the new year (plan section 9
* record-update rule).
*/
private function ensureNextYearEnrollment(StudentPromotionRecord $record): ?int
{
$existing = Enrollment::query()
->where('student_id', $record->student_id)
->where('school_year', $record->next_school_year)
->first();
$payload = [
'student_id' => $record->student_id,
'parent_id' => $record->parent_id,
'school_year' => $record->next_school_year,
'enrollment_status' => 'enrolled',
'admission_status' => 'accepted',
'is_withdrawn' => false,
'enrollment_date' => now()->toDateString(),
];
if ($existing) {
$existing->fill($payload);
$existing->save();
return (int) $existing->getKey();
}
$created = Enrollment::query()->create($payload);
return $created ? (int) $created->getKey() : null;
}
private function presentRecord(StudentPromotionRecord $record, ?Student $student): array
{
return [
'promotion_id' => (int) $record->getKey(),
'student_id' => (int) $record->student_id,
'student_name' => $student
? trim((string) $student->firstname.' '.(string) $student->lastname)
: null,
'current_level' => $record->current_level_name,
'promoted_level' => $record->promoted_level_name,
'current_school_year' => $record->current_school_year,
'next_school_year' => $record->next_school_year,
'promotion_status' => $record->promotion_status,
'enrollment_status' => $record->enrollment_status,
'enrollment_deadline' => $record->enrollment_deadline?->toDateString(),
'enrollment_started_at' => $record->enrollment_started_at?->toDateTimeString(),
'enrollment_completed_at' => $record->enrollment_completed_at?->toDateTimeString(),
'parent_action_required' => in_array(
$record->promotion_status,
StudentPromotionRecord::parentActionableStatuses(),
true
),
'checklist' => [
'info_confirmed' => (bool) $record->info_confirmed,
'documents_uploaded' => (bool) $record->documents_uploaded,
'agreement_accepted' => (bool) $record->agreement_accepted,
'payment_completed' => (bool) $record->payment_completed,
],
'final_average' => $record->final_average !== null ? (float) $record->final_average : null,
'passed_current_level' => $record->passed_current_level,
];
}
private function parentBannerMessage(EloquentCollection $records): ?string
{
$actionable = $records->first(function (StudentPromotionRecord $r) {
return in_array(
$r->promotion_status,
StudentPromotionRecord::parentActionableStatuses(),
true
);
});
if (! $actionable) {
return null;
}
$level = $actionable->promoted_level_name ?: 'the next level';
$deadline = $actionable->enrollment_deadline?->toDateString();
$deadlineText = $deadline ? 'by '.$deadline : 'as soon as possible';
return sprintf(
'Your child is eligible for promotion to %s. Please complete enrollment for the new school year %s.',
$level,
$deadlineText
);
}
private function assertParentOwns(StudentPromotionRecord $record, int $parentId): void
{
if ($parentId <= 0) {
throw new RuntimeException('Authenticated parent is required.');
}
if ((int) $record->parent_id !== $parentId) {
$studentParent = (int) (Student::query()->where('id', $record->student_id)->value('parent_id') ?? 0);
if ($studentParent !== $parentId) {
throw new RuntimeException('You are not authorised to act on this promotion record.');
}
}
}
private function assertActionable(StudentPromotionRecord $record): void
{
$actionable = StudentPromotionRecord::parentActionableStatuses();
if (! in_array($record->promotion_status, $actionable, true)) {
throw new RuntimeException(sprintf(
'Promotion record is not actionable in status "%s".',
$record->promotion_status
));
}
}
private function guessNextSchoolYear(): ?string
{
$current = (string) (Configuration::getConfigValueByKey('school_year') ?? '');
if (! preg_match('/^(\d{4})-(\d{4})$/', $current, $m)) {
return null;
}
return ((int) $m[1] + 1).'-'.((int) $m[2] + 1);
}
}