update api and add more features
This commit is contained in:
@@ -0,0 +1,391 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user