392 lines
14 KiB
PHP
392 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);
|
|
}
|
|
}
|