update api and add more features
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Promotions;
|
||||
|
||||
use App\Models\ClassSection;
|
||||
use App\Models\LevelProgression;
|
||||
use App\Models\SchoolClass;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Resolves current → next level using the configurable
|
||||
* `level_progressions` table (plan section 10).
|
||||
*
|
||||
* Falls back to `class_id + 1` only if no mapping is found, matching
|
||||
* the legacy behaviour in {@see \App\Services\School\AccountEventService}.
|
||||
*/
|
||||
class LevelProgressionService
|
||||
{
|
||||
/**
|
||||
* Default mapping shipped with the plan doc; used to seed the table
|
||||
* if no rows exist when the service is first asked for a list.
|
||||
*/
|
||||
public const DEFAULT_MAP = [
|
||||
['current' => 'KG1', 'next' => 'KG2', 'order' => 10, 'terminal' => false],
|
||||
['current' => 'KG2', 'next' => 'Grade 1', 'order' => 20, 'terminal' => false],
|
||||
['current' => 'Grade 1','next' => 'Grade 2', 'order' => 30, 'terminal' => false],
|
||||
['current' => 'Grade 2','next' => 'Grade 3', 'order' => 40, 'terminal' => false],
|
||||
['current' => 'Grade 3','next' => 'Grade 4', 'order' => 50, 'terminal' => false],
|
||||
['current' => 'Grade 4','next' => 'Grade 5', 'order' => 60, 'terminal' => false],
|
||||
['current' => 'Grade 5','next' => 'Grade 6', 'order' => 70, 'terminal' => false],
|
||||
['current' => 'Grade 6','next' => 'Grade 7', 'order' => 80, 'terminal' => false],
|
||||
['current' => 'Grade 7','next' => 'Grade 8', 'order' => 90, 'terminal' => false],
|
||||
['current' => 'Grade 8','next' => 'Grade 9', 'order' => 100, 'terminal' => false],
|
||||
['current' => 'Grade 9','next' => 'Youth', 'order' => 110, 'terminal' => false],
|
||||
['current' => 'Youth', 'next' => null, 'order' => 120, 'terminal' => true],
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns all configured level progressions, seeding defaults if the
|
||||
* table is empty.
|
||||
*
|
||||
* @return Collection<int,LevelProgression>
|
||||
*/
|
||||
public function list(bool $activeOnly = true): Collection
|
||||
{
|
||||
$query = LevelProgression::query()->ordered();
|
||||
if ($activeOnly) {
|
||||
$query->active();
|
||||
}
|
||||
$rows = $query->get();
|
||||
|
||||
if ($rows->isEmpty() && $activeOnly) {
|
||||
$this->seedDefaults();
|
||||
$rows = LevelProgression::query()->ordered()->active()->get();
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve next-level information for a student based on their current
|
||||
* `class_id` (i.e. classes.id). Returns null if no progression rule
|
||||
* matches and no fallback can be derived.
|
||||
*
|
||||
* @return array{
|
||||
* current_level_id:?int,
|
||||
* current_level_name:?string,
|
||||
* next_level_id:?int,
|
||||
* next_level_name:?string,
|
||||
* is_terminal:bool
|
||||
* }|null
|
||||
*/
|
||||
public function resolveByCurrentClassId(?int $currentClassId): ?array
|
||||
{
|
||||
if ($currentClassId === null || $currentClassId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$current = SchoolClass::query()->find($currentClassId);
|
||||
if (!$current) {
|
||||
return null;
|
||||
}
|
||||
$currentName = (string) ($current->class_name ?? '');
|
||||
|
||||
$progression = LevelProgression::findByCurrentLevelId($currentClassId)
|
||||
?: ($currentName !== '' ? LevelProgression::findByCurrentLevelName($currentName) : null);
|
||||
|
||||
if ($progression) {
|
||||
return [
|
||||
'current_level_id' => $currentClassId,
|
||||
'current_level_name' => $currentName !== '' ? $currentName : $progression->current_level_name,
|
||||
'next_level_id' => $progression->next_level_id !== null ? (int) $progression->next_level_id : null,
|
||||
'next_level_name' => $progression->next_level_name,
|
||||
'is_terminal' => (bool) $progression->is_terminal,
|
||||
];
|
||||
}
|
||||
|
||||
// Legacy fallback: numeric next class id (matches AccountEventService::preparePromotionQueue).
|
||||
$nextId = $currentClassId + 1;
|
||||
$next = SchoolClass::query()->find($nextId);
|
||||
|
||||
return [
|
||||
'current_level_id' => $currentClassId,
|
||||
'current_level_name' => $currentName !== '' ? $currentName : null,
|
||||
'next_level_id' => $next ? (int) $next->id : null,
|
||||
'next_level_name' => $next ? (string) $next->class_name : null,
|
||||
'is_terminal' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve next-level info from a `classSection` row id (the
|
||||
* "from_class_section_id" used elsewhere).
|
||||
*/
|
||||
public function resolveByCurrentClassSectionId(?int $classSectionId): ?array
|
||||
{
|
||||
if ($classSectionId === null || $classSectionId <= 0) {
|
||||
return null;
|
||||
}
|
||||
$classId = ClassSection::getClassId($classSectionId);
|
||||
if (!$classId) {
|
||||
return null;
|
||||
}
|
||||
return $this->resolveByCurrentClassId((int) $classId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the table with the default mapping from {@see DEFAULT_MAP}.
|
||||
* Idempotent – relies on the unique key on `current_level_name`.
|
||||
*/
|
||||
public function seedDefaults(): int
|
||||
{
|
||||
$created = 0;
|
||||
foreach (self::DEFAULT_MAP as $row) {
|
||||
$existing = LevelProgression::query()
|
||||
->whereRaw('LOWER(current_level_name) = ?', [strtolower($row['current'])])
|
||||
->first();
|
||||
if ($existing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentClassId = $this->resolveClassIdByName($row['current']);
|
||||
$nextClassId = isset($row['next']) && $row['next'] !== null
|
||||
? $this->resolveClassIdByName($row['next'])
|
||||
: null;
|
||||
|
||||
LevelProgression::query()->create([
|
||||
'current_level_id' => $currentClassId,
|
||||
'current_level_name' => $row['current'],
|
||||
'next_level_id' => $nextClassId,
|
||||
'next_level_name' => $row['next'] ?? null,
|
||||
'order_index' => (int) ($row['order'] ?? 0),
|
||||
'is_terminal' => (bool) ($row['terminal'] ?? false),
|
||||
'is_active' => true,
|
||||
]);
|
||||
$created++;
|
||||
}
|
||||
|
||||
return $created;
|
||||
}
|
||||
|
||||
public function upsertMapping(array $payload): LevelProgression
|
||||
{
|
||||
$currentName = (string) ($payload['current_level_name'] ?? '');
|
||||
if ($currentName === '') {
|
||||
throw new \InvalidArgumentException('current_level_name is required');
|
||||
}
|
||||
|
||||
$existing = LevelProgression::findByCurrentLevelName($currentName);
|
||||
$data = [
|
||||
'current_level_id' => isset($payload['current_level_id']) ? (int) $payload['current_level_id'] : ($existing->current_level_id ?? null),
|
||||
'current_level_name' => $currentName,
|
||||
'next_level_id' => isset($payload['next_level_id']) ? (int) $payload['next_level_id'] : null,
|
||||
'next_level_name' => $payload['next_level_name'] ?? null,
|
||||
'order_index' => isset($payload['order_index']) ? (int) $payload['order_index'] : ($existing->order_index ?? 0),
|
||||
'is_terminal' => (bool) ($payload['is_terminal'] ?? ($existing->is_terminal ?? false)),
|
||||
'is_active' => array_key_exists('is_active', $payload) ? (bool) $payload['is_active'] : ($existing->is_active ?? true),
|
||||
'notes' => $payload['notes'] ?? ($existing->notes ?? null),
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$existing->fill($data);
|
||||
$existing->save();
|
||||
return $existing;
|
||||
}
|
||||
|
||||
return LevelProgression::query()->create($data);
|
||||
}
|
||||
|
||||
private function resolveClassIdByName(?string $name): ?int
|
||||
{
|
||||
if ($name === null || $name === '') {
|
||||
return null;
|
||||
}
|
||||
$row = SchoolClass::query()
|
||||
->whereRaw('LOWER(class_name) = ?', [strtolower(trim($name))])
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
return $row ? (int) $row->id : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Promotions;
|
||||
|
||||
use App\Models\PromotionAuditLog;
|
||||
use App\Models\StudentPromotionRecord;
|
||||
|
||||
/**
|
||||
* Centralised writer for the promotion audit trail (plan section 18).
|
||||
*/
|
||||
class PromotionAuditService
|
||||
{
|
||||
public function logRecordCreated(StudentPromotionRecord $record, ?int $userId, ?string $notes = null): PromotionAuditLog
|
||||
{
|
||||
return $this->log($record, PromotionAuditLog::ACTION_RECORD_CREATED, [
|
||||
'user_id' => $userId,
|
||||
'new_value' => $record->promotion_status,
|
||||
'notes' => $notes,
|
||||
]);
|
||||
}
|
||||
|
||||
public function logStatusChange(
|
||||
StudentPromotionRecord $record,
|
||||
?string $oldStatus,
|
||||
string $newStatus,
|
||||
?int $userId,
|
||||
?string $notes = null
|
||||
): PromotionAuditLog {
|
||||
return $this->log($record, PromotionAuditLog::ACTION_STATUS_CHANGED, [
|
||||
'user_id' => $userId,
|
||||
'field' => 'promotion_status',
|
||||
'old_value' => $oldStatus,
|
||||
'new_value' => $newStatus,
|
||||
'notes' => $notes,
|
||||
]);
|
||||
}
|
||||
|
||||
public function logEligibilityEvaluation(
|
||||
StudentPromotionRecord $record,
|
||||
bool $passed,
|
||||
?int $userId,
|
||||
?string $notes = null
|
||||
): PromotionAuditLog {
|
||||
return $this->log($record, PromotionAuditLog::ACTION_ELIGIBILITY_EVALUATED, [
|
||||
'user_id' => $userId,
|
||||
'field' => 'passed_current_level',
|
||||
'new_value' => $passed ? '1' : '0',
|
||||
'notes' => $notes,
|
||||
]);
|
||||
}
|
||||
|
||||
public function logParentNotified(StudentPromotionRecord $record, ?int $userId, ?string $notes = null): PromotionAuditLog
|
||||
{
|
||||
return $this->log($record, PromotionAuditLog::ACTION_PARENT_NOTIFIED, [
|
||||
'user_id' => $userId,
|
||||
'notes' => $notes,
|
||||
]);
|
||||
}
|
||||
|
||||
public function logEnrollmentStepCompleted(
|
||||
StudentPromotionRecord $record,
|
||||
string $field,
|
||||
?int $userId,
|
||||
?string $notes = null
|
||||
): PromotionAuditLog {
|
||||
return $this->log($record, PromotionAuditLog::ACTION_ENROLLMENT_STEP_COMPLETED, [
|
||||
'user_id' => $userId,
|
||||
'field' => $field,
|
||||
'new_value' => '1',
|
||||
'notes' => $notes,
|
||||
]);
|
||||
}
|
||||
|
||||
public function logEnrollmentStarted(StudentPromotionRecord $record, ?int $userId): PromotionAuditLog
|
||||
{
|
||||
return $this->log($record, PromotionAuditLog::ACTION_ENROLLMENT_STARTED, [
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
public function logEnrollmentCompleted(StudentPromotionRecord $record, ?int $userId): PromotionAuditLog
|
||||
{
|
||||
return $this->log($record, PromotionAuditLog::ACTION_ENROLLMENT_COMPLETED, [
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
public function logPromotionFinalized(StudentPromotionRecord $record, ?int $userId): PromotionAuditLog
|
||||
{
|
||||
return $this->log($record, PromotionAuditLog::ACTION_PROMOTION_FINALIZED, [
|
||||
'user_id' => $userId,
|
||||
'new_value' => $record->promotion_status,
|
||||
]);
|
||||
}
|
||||
|
||||
public function logDeadlineSet(
|
||||
StudentPromotionRecord $record,
|
||||
?string $oldDeadline,
|
||||
?string $newDeadline,
|
||||
?int $userId
|
||||
): PromotionAuditLog {
|
||||
return $this->log($record, PromotionAuditLog::ACTION_DEADLINE_SET, [
|
||||
'user_id' => $userId,
|
||||
'field' => 'enrollment_deadline',
|
||||
'old_value' => $oldDeadline,
|
||||
'new_value' => $newDeadline,
|
||||
]);
|
||||
}
|
||||
|
||||
public function logReminderSent(
|
||||
StudentPromotionRecord $record,
|
||||
string $reminderType,
|
||||
?int $userId
|
||||
): PromotionAuditLog {
|
||||
return $this->log($record, PromotionAuditLog::ACTION_REMINDER_SENT, [
|
||||
'user_id' => $userId,
|
||||
'field' => 'reminder_type',
|
||||
'new_value' => $reminderType,
|
||||
]);
|
||||
}
|
||||
|
||||
public function logDeadlineExpired(StudentPromotionRecord $record): PromotionAuditLog
|
||||
{
|
||||
return $this->log($record, PromotionAuditLog::ACTION_DEADLINE_EXPIRED, []);
|
||||
}
|
||||
|
||||
public function logManualOverride(
|
||||
StudentPromotionRecord $record,
|
||||
string $field,
|
||||
?string $oldValue,
|
||||
?string $newValue,
|
||||
?int $userId,
|
||||
?string $notes = null
|
||||
): PromotionAuditLog {
|
||||
return $this->log($record, PromotionAuditLog::ACTION_MANUAL_OVERRIDE, [
|
||||
'user_id' => $userId,
|
||||
'field' => $field,
|
||||
'old_value' => $oldValue,
|
||||
'new_value' => $newValue,
|
||||
'notes' => $notes,
|
||||
]);
|
||||
}
|
||||
|
||||
private function log(StudentPromotionRecord $record, string $action, array $extra): PromotionAuditLog
|
||||
{
|
||||
return PromotionAuditLog::query()->create(array_merge([
|
||||
'promotion_id' => (int) $record->getKey(),
|
||||
'student_id' => (int) $record->student_id,
|
||||
'action_type' => $action,
|
||||
'performed_at' => now(),
|
||||
], $extra));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Promotions;
|
||||
|
||||
use App\Models\ClassSection;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Student;
|
||||
use App\Models\StudentPromotionRecord;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Implements the eligibility engine described in plan sections 5–7.
|
||||
*
|
||||
* Builds (or refreshes) one `student_promotion_records` row per
|
||||
* student per next-school-year and assigns a promotion status based on
|
||||
* academic results stored in `semester_scores`.
|
||||
*/
|
||||
class PromotionEligibilityService
|
||||
{
|
||||
public const DEFAULT_PASS_THRESHOLD = 60.0;
|
||||
|
||||
public function __construct(
|
||||
private LevelProgressionService $progression,
|
||||
private PromotionStatusService $statusService,
|
||||
private PromotionAuditService $audit
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate eligibility for a single student.
|
||||
*
|
||||
* @param int|null $userId actor id for audit
|
||||
*/
|
||||
public function evaluateStudent(
|
||||
int $studentId,
|
||||
?string $currentSchoolYear = null,
|
||||
?int $userId = null
|
||||
): ?StudentPromotionRecord {
|
||||
$student = Student::query()->find($studentId);
|
||||
if (!$student) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$current = $currentSchoolYear ?: $this->resolveCurrentSchoolYear();
|
||||
if ($current === null) {
|
||||
return null;
|
||||
}
|
||||
$next = $this->nextSchoolYear($current);
|
||||
if ($next === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->evaluateOne($student, $current, $next, $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate eligibility for a class section worth of students at once.
|
||||
*
|
||||
* @return array{ evaluated:int, eligible:int, repeated:int, on_hold:int, conditional:int, graduated:int, errors:array<int,string> }
|
||||
*/
|
||||
public function evaluateClassSection(
|
||||
int $classSectionId,
|
||||
?string $currentSchoolYear = null,
|
||||
?int $userId = null
|
||||
): array {
|
||||
$current = $currentSchoolYear ?: $this->resolveCurrentSchoolYear();
|
||||
if ($current === null) {
|
||||
return $this->emptySummary();
|
||||
}
|
||||
$next = $this->nextSchoolYear($current);
|
||||
if ($next === null) {
|
||||
return $this->emptySummary();
|
||||
}
|
||||
|
||||
$studentIds = DB::table('student_class')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $current)
|
||||
->pluck('student_id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
|
||||
$summary = $this->emptySummary();
|
||||
foreach ($studentIds as $studentId) {
|
||||
$student = Student::query()->find($studentId);
|
||||
if (!$student) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$record = $this->evaluateOne($student, $current, $next, $userId);
|
||||
if ($record) {
|
||||
$summary['evaluated']++;
|
||||
$key = $this->statusBucket($record->promotion_status);
|
||||
if ($key !== null) {
|
||||
$summary[$key]++;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Promotion eligibility evaluation failed', [
|
||||
'student_id' => $studentId,
|
||||
'exception' => $e,
|
||||
]);
|
||||
$summary['errors'][] = sprintf('Student %d: %s', $studentId, $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate eligibility for an entire school year (all students with
|
||||
* class assignments). Convenience wrapper around `evaluateOne`.
|
||||
*/
|
||||
public function evaluateSchoolYear(?string $currentSchoolYear = null, ?int $userId = null): array
|
||||
{
|
||||
$current = $currentSchoolYear ?: $this->resolveCurrentSchoolYear();
|
||||
if ($current === null) {
|
||||
return $this->emptySummary();
|
||||
}
|
||||
$next = $this->nextSchoolYear($current);
|
||||
if ($next === null) {
|
||||
return $this->emptySummary();
|
||||
}
|
||||
|
||||
$studentIds = DB::table('student_class')
|
||||
->where('school_year', $current)
|
||||
->distinct()
|
||||
->pluck('student_id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
|
||||
$summary = $this->emptySummary();
|
||||
foreach ($studentIds as $studentId) {
|
||||
$student = Student::query()->find($studentId);
|
||||
if (!$student) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$record = $this->evaluateOne($student, $current, $next, $userId);
|
||||
if ($record) {
|
||||
$summary['evaluated']++;
|
||||
$key = $this->statusBucket($record->promotion_status);
|
||||
if ($key !== null) {
|
||||
$summary[$key]++;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Promotion eligibility evaluation failed', [
|
||||
'student_id' => $studentId,
|
||||
'exception' => $e,
|
||||
]);
|
||||
$summary['errors'][] = sprintf('Student %d: %s', $studentId, $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core per-student logic implementing section 7's decision tree.
|
||||
*/
|
||||
public function evaluateOne(
|
||||
Student $student,
|
||||
string $currentSchoolYear,
|
||||
string $nextSchoolYear,
|
||||
?int $userId = null
|
||||
): StudentPromotionRecord {
|
||||
$studentId = (int) $student->getKey();
|
||||
|
||||
$sectionId = (int) DB::table('student_class')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $currentSchoolYear)
|
||||
->orderByDesc('updated_at')
|
||||
->orderByDesc('id')
|
||||
->value('class_section_id');
|
||||
|
||||
$progressionInfo = $this->progression->resolveByCurrentClassSectionId($sectionId);
|
||||
if ($progressionInfo === null && $sectionId > 0) {
|
||||
// Even without a mapping we still want to record the eligibility decision.
|
||||
$progressionInfo = [
|
||||
'current_level_id' => null,
|
||||
'current_level_name' => ClassSection::getClassSectionNameBySectionId($sectionId),
|
||||
'next_level_id' => null,
|
||||
'next_level_name' => null,
|
||||
'is_terminal' => false,
|
||||
];
|
||||
}
|
||||
$progressionInfo ??= [
|
||||
'current_level_id' => null,
|
||||
'current_level_name' => $student->registration_grade,
|
||||
'next_level_id' => null,
|
||||
'next_level_name' => null,
|
||||
'is_terminal' => false,
|
||||
];
|
||||
|
||||
$scoreInfo = $this->loadAcademicResult($studentId, $currentSchoolYear);
|
||||
$passed = $scoreInfo['passed'];
|
||||
$finalAvg = $scoreInfo['final_average'];
|
||||
|
||||
return DB::transaction(function () use (
|
||||
$student,
|
||||
$studentId,
|
||||
$currentSchoolYear,
|
||||
$nextSchoolYear,
|
||||
$progressionInfo,
|
||||
$passed,
|
||||
$finalAvg,
|
||||
$userId,
|
||||
$scoreInfo
|
||||
) {
|
||||
$existing = StudentPromotionRecord::query()
|
||||
->where('student_id', $studentId)
|
||||
->where('next_school_year', $nextSchoolYear)
|
||||
->first();
|
||||
$isNew = $existing === null;
|
||||
|
||||
$record = $existing ?: new StudentPromotionRecord();
|
||||
$record->fill([
|
||||
'student_id' => $studentId,
|
||||
'parent_id' => (int) ($student->parent_id ?? 0) ?: null,
|
||||
'current_school_year' => $currentSchoolYear,
|
||||
'next_school_year' => $nextSchoolYear,
|
||||
'current_level_id' => $progressionInfo['current_level_id'],
|
||||
'current_level_name' => $progressionInfo['current_level_name'],
|
||||
'promoted_level_id' => $progressionInfo['next_level_id'],
|
||||
'promoted_level_name' => $progressionInfo['next_level_name'],
|
||||
'final_average' => $finalAvg,
|
||||
'eligibility_notes' => $scoreInfo['notes'],
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
if ($isNew) {
|
||||
$record->promotion_status = StudentPromotionRecord::STATUS_NOT_REVIEWED;
|
||||
$record->enrollment_required = true;
|
||||
$record->enrollment_status = StudentPromotionRecord::ENROLLMENT_NOT_STARTED;
|
||||
}
|
||||
|
||||
$previousStatus = (string) ($record->promotion_status ?? StudentPromotionRecord::STATUS_NOT_REVIEWED);
|
||||
$record->passed_current_level = $passed;
|
||||
$newStatus = $this->resolveStatusFromEvaluation(
|
||||
$passed,
|
||||
$progressionInfo['is_terminal'] ?? false,
|
||||
$scoreInfo['has_data']
|
||||
);
|
||||
|
||||
// Preserve admin overrides for terminal/holds.
|
||||
if (in_array($previousStatus, [
|
||||
StudentPromotionRecord::STATUS_WITHDRAWN,
|
||||
StudentPromotionRecord::STATUS_GRADUATED,
|
||||
StudentPromotionRecord::STATUS_PROMOTED_AND_ENROLLED,
|
||||
StudentPromotionRecord::STATUS_NOT_ENROLLED,
|
||||
], true)) {
|
||||
$newStatus = $previousStatus;
|
||||
}
|
||||
|
||||
$record->promotion_status = $newStatus;
|
||||
$record->save();
|
||||
|
||||
if ($isNew) {
|
||||
$this->audit->logRecordCreated($record, $userId);
|
||||
}
|
||||
$this->audit->logEligibilityEvaluation(
|
||||
$record,
|
||||
(bool) $passed,
|
||||
$userId,
|
||||
$scoreInfo['notes']
|
||||
);
|
||||
if ($previousStatus !== $newStatus) {
|
||||
$this->audit->logStatusChange($record, $previousStatus, $newStatus, $userId, 'Auto eligibility evaluation');
|
||||
}
|
||||
|
||||
return $record;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Best effort lookup of the student's final academic result for the
|
||||
* given school year. Treats missing fall + spring scores as "no
|
||||
* data" → on hold (plan section 4: On Hold for missing result).
|
||||
*
|
||||
* @return array{ passed:?bool, final_average:?float, notes:?string, has_data:bool }
|
||||
*/
|
||||
private function loadAcademicResult(int $studentId, string $schoolYear): array
|
||||
{
|
||||
$rows = DB::table('semester_scores')
|
||||
->select('semester', 'semester_score')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->get();
|
||||
|
||||
$fall = null;
|
||||
$spring = null;
|
||||
foreach ($rows as $row) {
|
||||
$semester = strtolower((string) ($row->semester ?? ''));
|
||||
$score = isset($row->semester_score) ? (float) $row->semester_score : null;
|
||||
if ($semester === 'fall') {
|
||||
$fall = $score;
|
||||
} elseif ($semester === 'spring') {
|
||||
$spring = $score;
|
||||
}
|
||||
}
|
||||
|
||||
$threshold = $this->passThreshold();
|
||||
|
||||
if ($fall === null && $spring === null) {
|
||||
return [
|
||||
'passed' => null,
|
||||
'final_average' => null,
|
||||
'notes' => 'Missing fall and spring scores',
|
||||
'has_data' => false,
|
||||
];
|
||||
}
|
||||
if ($fall === null || $spring === null) {
|
||||
$existing = $fall ?? $spring;
|
||||
return [
|
||||
'passed' => null,
|
||||
'final_average' => $existing,
|
||||
'notes' => 'Awaiting both fall and spring scores',
|
||||
'has_data' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$avg = round(($fall + $spring) / 2.0, 2);
|
||||
return [
|
||||
'passed' => $avg >= $threshold,
|
||||
'final_average' => $avg,
|
||||
'notes' => sprintf('Final average %.2f (threshold %.2f)', $avg, $threshold),
|
||||
'has_data' => true,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveStatusFromEvaluation(?bool $passed, bool $terminal, bool $hasData): string
|
||||
{
|
||||
if (!$hasData) {
|
||||
return StudentPromotionRecord::STATUS_ON_HOLD;
|
||||
}
|
||||
if ($passed === false) {
|
||||
return StudentPromotionRecord::STATUS_REPEATED;
|
||||
}
|
||||
if ($terminal) {
|
||||
return StudentPromotionRecord::STATUS_GRADUATED;
|
||||
}
|
||||
return StudentPromotionRecord::STATUS_AWAITING_PARENT;
|
||||
}
|
||||
|
||||
private function statusBucket(string $status): ?string
|
||||
{
|
||||
return match ($status) {
|
||||
StudentPromotionRecord::STATUS_AWAITING_PARENT,
|
||||
StudentPromotionRecord::STATUS_ELIGIBLE => 'eligible',
|
||||
StudentPromotionRecord::STATUS_REPEATED => 'repeated',
|
||||
StudentPromotionRecord::STATUS_ON_HOLD => 'on_hold',
|
||||
StudentPromotionRecord::STATUS_CONDITIONAL => 'conditional',
|
||||
StudentPromotionRecord::STATUS_GRADUATED => 'graduated',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function emptySummary(): array
|
||||
{
|
||||
return [
|
||||
'evaluated' => 0,
|
||||
'eligible' => 0,
|
||||
'repeated' => 0,
|
||||
'on_hold' => 0,
|
||||
'conditional' => 0,
|
||||
'graduated' => 0,
|
||||
'errors' => [],
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveCurrentSchoolYear(): ?string
|
||||
{
|
||||
$year = (string) (Configuration::getConfigValueByKey('school_year') ?? '');
|
||||
return $year !== '' ? $year : null;
|
||||
}
|
||||
|
||||
private function nextSchoolYear(string $current): ?string
|
||||
{
|
||||
if (!preg_match('/^(\d{4})-(\d{4})$/', $current, $m)) {
|
||||
return null;
|
||||
}
|
||||
return ((int) $m[1] + 1) . '-' . ((int) $m[2] + 1);
|
||||
}
|
||||
|
||||
private function passThreshold(): float
|
||||
{
|
||||
$configured = Configuration::getConfigValueByKey('promotion_pass_threshold');
|
||||
if ($configured !== null && is_numeric($configured)) {
|
||||
return (float) $configured;
|
||||
}
|
||||
return self::DEFAULT_PASS_THRESHOLD;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Promotions;
|
||||
|
||||
use App\Models\Student;
|
||||
use App\Models\StudentPromotionRecord;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Admin-side reads against `student_promotion_records` (plan sections 13
|
||||
* and 16). Always returns hydrated student / parent context so the
|
||||
* admin UI can render rows without N+1 queries.
|
||||
*/
|
||||
class PromotionQueryService
|
||||
{
|
||||
/**
|
||||
* Filterable list of promotion records for admin views.
|
||||
*
|
||||
* Filters supported:
|
||||
* - status: string|array
|
||||
* - next_school_year: string
|
||||
* - current_school_year: string
|
||||
* - parent_id: int
|
||||
* - student_id: int
|
||||
* - search: string (matches student first/last name)
|
||||
* - parent_action_required: bool (subset of parentActionableStatuses)
|
||||
*
|
||||
* Returns array<int, array<string, mixed>> when paginate=false,
|
||||
* otherwise a Laravel paginator instance.
|
||||
*
|
||||
* @param array{
|
||||
* status?: string|array<int,string>,
|
||||
* next_school_year?: string|null,
|
||||
* current_school_year?: string|null,
|
||||
* parent_id?: int|null,
|
||||
* student_id?: int|null,
|
||||
* search?: string|null,
|
||||
* parent_action_required?: bool,
|
||||
* per_page?: int|null,
|
||||
* } $filters
|
||||
*
|
||||
* @return array{
|
||||
* data: array<int,array<string,mixed>>,
|
||||
* total: int,
|
||||
* page: int,
|
||||
* per_page: int,
|
||||
* total_pages: int,
|
||||
* counts_by_status: array<string,int>
|
||||
* }
|
||||
*/
|
||||
public function list(array $filters): array
|
||||
{
|
||||
$query = StudentPromotionRecord::query();
|
||||
$this->applyFilters($query, $filters);
|
||||
|
||||
$perPage = isset($filters['per_page']) ? max(1, min(200, (int) $filters['per_page'])) : 50;
|
||||
$page = isset($filters['page']) ? max(1, (int) $filters['page']) : 1;
|
||||
|
||||
/** @var LengthAwarePaginator $paginator */
|
||||
$paginator = $query->orderByDesc('promotion_id')->paginate($perPage, ['*'], 'page', $page);
|
||||
|
||||
/** @var EloquentCollection<int,StudentPromotionRecord> $records */
|
||||
$records = $paginator->getCollection();
|
||||
$studentIds = $records->pluck('student_id')->unique()->values()->all();
|
||||
$parentIds = $records->pluck('parent_id')->filter()->unique()->values()->all();
|
||||
|
||||
$studentsById = !empty($studentIds)
|
||||
? Student::query()->whereIn('id', $studentIds)->get()->keyBy('id')
|
||||
: new EloquentCollection();
|
||||
$parentsById = !empty($parentIds)
|
||||
? User::query()->whereIn('id', $parentIds)->get()->keyBy('id')
|
||||
: new EloquentCollection();
|
||||
|
||||
$rows = $records->map(function (StudentPromotionRecord $record) use ($studentsById, $parentsById) {
|
||||
return $this->presentAdminRow(
|
||||
$record,
|
||||
$studentsById->get($record->student_id),
|
||||
$record->parent_id ? $parentsById->get($record->parent_id) : null
|
||||
);
|
||||
})->all();
|
||||
|
||||
return [
|
||||
'data' => array_values($rows),
|
||||
'total' => (int) $paginator->total(),
|
||||
'page' => (int) $paginator->currentPage(),
|
||||
'per_page' => (int) $paginator->perPage(),
|
||||
'total_pages' => (int) $paginator->lastPage(),
|
||||
'counts_by_status' => $this->countsByStatus($filters),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the per-status count of promotion records that match the
|
||||
* given filter set (ignoring `status` and pagination filters). Plan
|
||||
* section 16's reports rely on these counts.
|
||||
*
|
||||
* @return array<string,int>
|
||||
*/
|
||||
public function countsByStatus(array $filters): array
|
||||
{
|
||||
$query = StudentPromotionRecord::query();
|
||||
// Apply non-status filters only.
|
||||
$filtersSansStatus = $filters;
|
||||
unset($filtersSansStatus['status'], $filtersSansStatus['parent_action_required']);
|
||||
$this->applyFilters($query, $filtersSansStatus);
|
||||
|
||||
$rows = $query
|
||||
->select('promotion_status', DB::raw('count(*) as total'))
|
||||
->groupBy('promotion_status')
|
||||
->get();
|
||||
|
||||
$counts = array_fill_keys(StudentPromotionRecord::ALL_STATUSES, 0);
|
||||
foreach ($rows as $row) {
|
||||
$counts[(string) $row->promotion_status] = (int) $row->total;
|
||||
}
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrates a single record with student + parent context for the
|
||||
* admin detail view.
|
||||
*/
|
||||
public function detail(StudentPromotionRecord $record): array
|
||||
{
|
||||
$student = Student::query()->find($record->student_id);
|
||||
$parent = $record->parent_id ? User::query()->find($record->parent_id) : null;
|
||||
|
||||
return $this->presentAdminRow($record, $student, $parent, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<StudentPromotionRecord> $query
|
||||
*/
|
||||
private function applyFilters(Builder $query, array $filters): void
|
||||
{
|
||||
if (!empty($filters['status'])) {
|
||||
$statuses = is_array($filters['status']) ? $filters['status'] : [$filters['status']];
|
||||
$statuses = array_values(array_filter($statuses, static fn ($s) => is_string($s) && $s !== ''));
|
||||
if (!empty($statuses)) {
|
||||
$query->whereIn('promotion_status', $statuses);
|
||||
}
|
||||
}
|
||||
if (!empty($filters['parent_action_required'])) {
|
||||
$query->whereIn('promotion_status', StudentPromotionRecord::parentActionableStatuses());
|
||||
}
|
||||
if (!empty($filters['next_school_year'])) {
|
||||
$query->where('next_school_year', $filters['next_school_year']);
|
||||
}
|
||||
if (!empty($filters['current_school_year'])) {
|
||||
$query->where('current_school_year', $filters['current_school_year']);
|
||||
}
|
||||
if (!empty($filters['parent_id'])) {
|
||||
$query->where('parent_id', (int) $filters['parent_id']);
|
||||
}
|
||||
if (!empty($filters['student_id'])) {
|
||||
$query->where('student_id', (int) $filters['student_id']);
|
||||
}
|
||||
if (!empty($filters['search'])) {
|
||||
$search = '%' . strtolower((string) $filters['search']) . '%';
|
||||
$query->whereIn('student_id', function ($sub) use ($search) {
|
||||
$sub->select('id')
|
||||
->from('students')
|
||||
->where(function ($w) use ($search) {
|
||||
$w->whereRaw('LOWER(firstname) LIKE ?', [$search])
|
||||
->orWhereRaw('LOWER(lastname) LIKE ?', [$search]);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private function presentAdminRow(
|
||||
StudentPromotionRecord $record,
|
||||
?Student $student,
|
||||
?User $parent,
|
||||
bool $detail = false
|
||||
): array {
|
||||
$row = [
|
||||
'promotion_id' => (int) $record->getKey(),
|
||||
'student_id' => (int) $record->student_id,
|
||||
'student_name' => $student
|
||||
? trim((string) $student->firstname . ' ' . (string) $student->lastname)
|
||||
: null,
|
||||
'school_id' => $student->school_id ?? null,
|
||||
'parent_id' => $record->parent_id ? (int) $record->parent_id : null,
|
||||
'parent_name' => $parent
|
||||
? trim((string) $parent->firstname . ' ' . (string) $parent->lastname)
|
||||
: null,
|
||||
'parent_email' => $parent->email ?? null,
|
||||
'current_school_year' => $record->current_school_year,
|
||||
'next_school_year' => $record->next_school_year,
|
||||
'current_level' => $record->current_level_name,
|
||||
'promoted_level' => $record->promoted_level_name,
|
||||
'promotion_status' => $record->promotion_status,
|
||||
'enrollment_status' => $record->enrollment_status,
|
||||
'enrollment_deadline' => $record->enrollment_deadline?->toDateString(),
|
||||
'parent_notified_at' => $record->parent_notified_at?->toDateTimeString(),
|
||||
'enrollment_started_at' => $record->enrollment_started_at?->toDateTimeString(),
|
||||
'enrollment_completed_at' => $record->enrollment_completed_at?->toDateTimeString(),
|
||||
'promotion_finalized_at' => $record->promotion_finalized_at?->toDateTimeString(),
|
||||
'final_average' => $record->final_average !== null ? (float) $record->final_average : null,
|
||||
'passed_current_level' => $record->passed_current_level,
|
||||
'updated_by' => $record->updated_by ? (int) $record->updated_by : null,
|
||||
'updated_at' => $record->updated_at?->toDateTimeString(),
|
||||
];
|
||||
|
||||
if ($detail) {
|
||||
$row['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,
|
||||
];
|
||||
$row['eligibility_notes'] = $record->eligibility_notes;
|
||||
$row['enrollment_id'] = $record->enrollment_id ? (int) $record->enrollment_id : null;
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Promotions;
|
||||
|
||||
use App\Models\PromotionReminderLog;
|
||||
use App\Models\Student;
|
||||
use App\Models\StudentPromotionRecord;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Sends reminders to parents and persists a log entry per dispatch
|
||||
* (plan section 14).
|
||||
*
|
||||
* The actual delivery is delegated to whichever notification dispatcher
|
||||
* is registered. A nullable dispatcher is supported so the service can
|
||||
* be used from CLI/tests without external side effects.
|
||||
*/
|
||||
class PromotionReminderService
|
||||
{
|
||||
/** @var (callable(int $userId, string $title, string $body, array $channels):void)|null */
|
||||
private $dispatcher;
|
||||
|
||||
public function __construct(
|
||||
private PromotionAuditService $audit,
|
||||
?callable $dispatcher = null
|
||||
) {
|
||||
$this->dispatcher = $dispatcher;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a single reminder dispatch.
|
||||
*/
|
||||
public function send(
|
||||
StudentPromotionRecord $record,
|
||||
string $reminderType,
|
||||
?int $userId = null,
|
||||
?string $subject = null,
|
||||
?string $message = null,
|
||||
array $channels = ['in_app', 'email']
|
||||
): PromotionReminderLog {
|
||||
if (!in_array($reminderType, PromotionReminderLog::ALLOWED_TYPES, true)) {
|
||||
$reminderType = PromotionReminderLog::TYPE_MANUAL;
|
||||
}
|
||||
|
||||
$studentName = $this->studentName($record);
|
||||
$deadline = $record->enrollment_deadline?->toDateString();
|
||||
$level = $record->promoted_level_name ?: 'the next level';
|
||||
|
||||
$defaultSubject = 'Reminder: complete enrollment for the next school year';
|
||||
$defaultMessage = sprintf(
|
||||
'%s is eligible for promotion to %s.%s Please complete enrollment as soon as possible.',
|
||||
$studentName ?: 'Your child',
|
||||
$level,
|
||||
$deadline ? ' Deadline: ' . $deadline . '.' : ''
|
||||
);
|
||||
|
||||
$finalSubject = $subject ?: $defaultSubject;
|
||||
$finalMessage = $message ?: $defaultMessage;
|
||||
|
||||
return DB::transaction(function () use (
|
||||
$record,
|
||||
$reminderType,
|
||||
$finalSubject,
|
||||
$finalMessage,
|
||||
$channels,
|
||||
$userId
|
||||
) {
|
||||
$log = PromotionReminderLog::query()->create([
|
||||
'promotion_id' => (int) $record->getKey(),
|
||||
'student_id' => (int) $record->student_id,
|
||||
'parent_id' => (int) $record->parent_id ?: null,
|
||||
'reminder_type' => $reminderType,
|
||||
'channel' => implode(',', array_values(array_unique(array_filter($channels)))),
|
||||
'subject' => $finalSubject,
|
||||
'message' => $finalMessage,
|
||||
'sent_at' => now(),
|
||||
'sent_by' => $userId,
|
||||
]);
|
||||
|
||||
if ($record->parent_id) {
|
||||
$this->dispatch((int) $record->parent_id, $finalSubject, $finalMessage, $channels);
|
||||
}
|
||||
|
||||
if (!$record->parent_notified_at) {
|
||||
$record->parent_notified_at = now();
|
||||
$record->save();
|
||||
$this->audit->logParentNotified($record, $userId, $reminderType);
|
||||
}
|
||||
$this->audit->logReminderSent($record, $reminderType, $userId);
|
||||
|
||||
return $log;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan section 14 – schedule-driven reminders. Sends the next due
|
||||
* reminder type for every actionable record whose deadline is set.
|
||||
*
|
||||
* @return array{ processed:int, sent:int, skipped:int }
|
||||
*/
|
||||
public function dispatchScheduledReminders(?\DateTimeInterface $now = null, ?int $userId = null): array
|
||||
{
|
||||
$now = CarbonImmutable::instance($now ?: now());
|
||||
|
||||
$records = StudentPromotionRecord::query()
|
||||
->whereIn('promotion_status', StudentPromotionRecord::parentActionableStatuses())
|
||||
->whereNotNull('enrollment_deadline')
|
||||
->get();
|
||||
|
||||
$processed = 0;
|
||||
$sent = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($records as $record) {
|
||||
$processed++;
|
||||
$type = $this->resolveReminderType($record, $now);
|
||||
if ($type === null) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
$alreadySent = PromotionReminderLog::query()
|
||||
->where('promotion_id', $record->getKey())
|
||||
->where('reminder_type', $type)
|
||||
->exists();
|
||||
if ($alreadySent) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$this->send($record, $type, $userId);
|
||||
$sent++;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Promotion reminder dispatch failed', [
|
||||
'promotion_id' => $record->getKey(),
|
||||
'exception' => $e,
|
||||
]);
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'processed' => $processed,
|
||||
'sent' => $sent,
|
||||
'skipped' => $skipped,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the reminder type that should be sent next based on the
|
||||
* deadline / current time.
|
||||
*/
|
||||
private function resolveReminderType(StudentPromotionRecord $record, CarbonImmutable $now): ?string
|
||||
{
|
||||
$deadline = $record->enrollment_deadline ? CarbonImmutable::instance($record->enrollment_deadline) : null;
|
||||
if ($deadline === null) {
|
||||
return null;
|
||||
}
|
||||
$createdAt = $record->created_at ? CarbonImmutable::instance($record->created_at) : $now;
|
||||
|
||||
if ($now->greaterThan($deadline)) {
|
||||
return PromotionReminderLog::TYPE_EXPIRATION;
|
||||
}
|
||||
|
||||
$totalSeconds = max(1, $deadline->getTimestamp() - $createdAt->getTimestamp());
|
||||
$elapsedSeconds = max(0, $now->getTimestamp() - $createdAt->getTimestamp());
|
||||
$remainingSeconds = max(0, $deadline->getTimestamp() - $now->getTimestamp());
|
||||
$remainingDays = (int) floor($remainingSeconds / 86400);
|
||||
|
||||
if ($elapsedSeconds === 0) {
|
||||
return PromotionReminderLog::TYPE_FIRST;
|
||||
}
|
||||
if ($remainingDays <= 3) {
|
||||
return PromotionReminderLog::TYPE_FINAL;
|
||||
}
|
||||
if ($elapsedSeconds * 2 >= $totalSeconds) {
|
||||
return PromotionReminderLog::TYPE_HALFWAY;
|
||||
}
|
||||
return PromotionReminderLog::TYPE_FIRST;
|
||||
}
|
||||
|
||||
private function studentName(StudentPromotionRecord $record): ?string
|
||||
{
|
||||
$student = Student::query()->find($record->student_id);
|
||||
if (!$student) {
|
||||
return null;
|
||||
}
|
||||
$name = trim((string) ($student->firstname ?? '') . ' ' . (string) ($student->lastname ?? ''));
|
||||
return $name !== '' ? $name : null;
|
||||
}
|
||||
|
||||
private function dispatch(int $userId, string $subject, string $message, array $channels): void
|
||||
{
|
||||
if (!$this->dispatcher) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
($this->dispatcher)($userId, $subject, $message, $channels);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Promotion reminder dispatcher threw', [
|
||||
'user_id' => $userId,
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Promotions;
|
||||
|
||||
use App\Models\StudentPromotionRecord;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* State machine for promotion records (plan sections 3, 4, 7).
|
||||
*
|
||||
* All status transitions go through this service so the audit trail
|
||||
* stays in sync (plan section 18).
|
||||
*/
|
||||
class PromotionStatusService
|
||||
{
|
||||
public function __construct(private PromotionAuditService $audit)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of allowed transitions from current → set of next statuses.
|
||||
* Edge cases (manual overrides) are handled by `forceStatus`.
|
||||
*/
|
||||
private const ALLOWED_TRANSITIONS = [
|
||||
StudentPromotionRecord::STATUS_NOT_REVIEWED => [
|
||||
StudentPromotionRecord::STATUS_ELIGIBLE,
|
||||
StudentPromotionRecord::STATUS_AWAITING_PARENT,
|
||||
StudentPromotionRecord::STATUS_CONDITIONAL,
|
||||
StudentPromotionRecord::STATUS_REPEATED,
|
||||
StudentPromotionRecord::STATUS_ON_HOLD,
|
||||
StudentPromotionRecord::STATUS_GRADUATED,
|
||||
StudentPromotionRecord::STATUS_WITHDRAWN,
|
||||
],
|
||||
StudentPromotionRecord::STATUS_ELIGIBLE => [
|
||||
StudentPromotionRecord::STATUS_AWAITING_PARENT,
|
||||
StudentPromotionRecord::STATUS_ENROLLMENT_STARTED,
|
||||
StudentPromotionRecord::STATUS_CONDITIONAL,
|
||||
StudentPromotionRecord::STATUS_ON_HOLD,
|
||||
StudentPromotionRecord::STATUS_NOT_ENROLLED,
|
||||
StudentPromotionRecord::STATUS_WITHDRAWN,
|
||||
],
|
||||
StudentPromotionRecord::STATUS_AWAITING_PARENT => [
|
||||
StudentPromotionRecord::STATUS_ENROLLMENT_STARTED,
|
||||
StudentPromotionRecord::STATUS_PROMOTED_AND_ENROLLED,
|
||||
StudentPromotionRecord::STATUS_NOT_ENROLLED,
|
||||
StudentPromotionRecord::STATUS_ON_HOLD,
|
||||
StudentPromotionRecord::STATUS_WITHDRAWN,
|
||||
],
|
||||
StudentPromotionRecord::STATUS_ENROLLMENT_STARTED => [
|
||||
StudentPromotionRecord::STATUS_PROMOTED_AND_ENROLLED,
|
||||
StudentPromotionRecord::STATUS_AWAITING_PARENT,
|
||||
StudentPromotionRecord::STATUS_NOT_ENROLLED,
|
||||
StudentPromotionRecord::STATUS_ON_HOLD,
|
||||
StudentPromotionRecord::STATUS_WITHDRAWN,
|
||||
],
|
||||
StudentPromotionRecord::STATUS_CONDITIONAL => [
|
||||
StudentPromotionRecord::STATUS_ELIGIBLE,
|
||||
StudentPromotionRecord::STATUS_AWAITING_PARENT,
|
||||
StudentPromotionRecord::STATUS_REPEATED,
|
||||
StudentPromotionRecord::STATUS_ON_HOLD,
|
||||
StudentPromotionRecord::STATUS_NOT_REVIEWED,
|
||||
],
|
||||
StudentPromotionRecord::STATUS_ON_HOLD => [
|
||||
StudentPromotionRecord::STATUS_NOT_REVIEWED,
|
||||
StudentPromotionRecord::STATUS_ELIGIBLE,
|
||||
StudentPromotionRecord::STATUS_AWAITING_PARENT,
|
||||
StudentPromotionRecord::STATUS_REPEATED,
|
||||
StudentPromotionRecord::STATUS_CONDITIONAL,
|
||||
StudentPromotionRecord::STATUS_WITHDRAWN,
|
||||
StudentPromotionRecord::STATUS_NOT_ENROLLED,
|
||||
],
|
||||
// Terminal statuses are intentionally not in the map (no auto
|
||||
// transitions); admins can still override via `forceStatus`.
|
||||
];
|
||||
|
||||
public function canTransition(string $from, string $to): bool
|
||||
{
|
||||
if ($from === $to) {
|
||||
return true;
|
||||
}
|
||||
if (!in_array($to, StudentPromotionRecord::ALL_STATUSES, true)) {
|
||||
return false;
|
||||
}
|
||||
$allowed = self::ALLOWED_TRANSITIONS[$from] ?? [];
|
||||
return in_array($to, $allowed, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a status transition with audit logging. Returns the saved
|
||||
* record. Throws if the new status is invalid or the transition is
|
||||
* not allowed.
|
||||
*/
|
||||
public function transition(
|
||||
StudentPromotionRecord $record,
|
||||
string $newStatus,
|
||||
?int $userId = null,
|
||||
?string $notes = null
|
||||
): StudentPromotionRecord {
|
||||
if (!in_array($newStatus, StudentPromotionRecord::ALL_STATUSES, true)) {
|
||||
throw new InvalidArgumentException(sprintf('Unknown promotion status "%s".', $newStatus));
|
||||
}
|
||||
|
||||
$oldStatus = (string) $record->promotion_status;
|
||||
if ($oldStatus === $newStatus) {
|
||||
return $record;
|
||||
}
|
||||
if (!$this->canTransition($oldStatus, $newStatus)) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Transition from "%s" to "%s" is not allowed.',
|
||||
$oldStatus,
|
||||
$newStatus
|
||||
));
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($record, $oldStatus, $newStatus, $userId, $notes) {
|
||||
$record->promotion_status = $newStatus;
|
||||
$record->updated_by = $userId;
|
||||
$record->save();
|
||||
|
||||
$this->audit->logStatusChange($record, $oldStatus, $newStatus, $userId, $notes);
|
||||
|
||||
return $record;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a status change ignoring the transition map (e.g. admin
|
||||
* override). The audit trail records this as a manual override.
|
||||
*/
|
||||
public function forceStatus(
|
||||
StudentPromotionRecord $record,
|
||||
string $newStatus,
|
||||
?int $userId = null,
|
||||
?string $notes = null
|
||||
): StudentPromotionRecord {
|
||||
if (!in_array($newStatus, StudentPromotionRecord::ALL_STATUSES, true)) {
|
||||
throw new InvalidArgumentException(sprintf('Unknown promotion status "%s".', $newStatus));
|
||||
}
|
||||
|
||||
$oldStatus = (string) $record->promotion_status;
|
||||
if ($oldStatus === $newStatus) {
|
||||
return $record;
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($record, $oldStatus, $newStatus, $userId, $notes) {
|
||||
$record->promotion_status = $newStatus;
|
||||
$record->updated_by = $userId;
|
||||
$record->save();
|
||||
|
||||
$this->audit->logManualOverride(
|
||||
$record,
|
||||
'promotion_status',
|
||||
$oldStatus,
|
||||
$newStatus,
|
||||
$userId,
|
||||
$notes ?? 'Manual status override'
|
||||
);
|
||||
|
||||
return $record;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user