add test batches
This commit is contained in:
@@ -5,7 +5,6 @@ namespace App\Services\Promotions;
|
||||
use App\Models\ClassSection;
|
||||
use App\Models\LevelProgression;
|
||||
use App\Models\SchoolClass;
|
||||
use App\Services\School\AccountEventService;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
@@ -13,7 +12,7 @@ use Illuminate\Support\Collection;
|
||||
* `level_progressions` table (plan section 10).
|
||||
*
|
||||
* Falls back to `class_id + 1` only if no mapping is found, matching
|
||||
* the legacy behaviour in {@see AccountEventService}.
|
||||
* the legacy behaviour in {@see \App\Services\School\AccountEventService}.
|
||||
*/
|
||||
class LevelProgressionService
|
||||
{
|
||||
@@ -24,15 +23,15 @@ class LevelProgressionService
|
||||
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' => '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],
|
||||
];
|
||||
|
||||
@@ -78,7 +77,7 @@ class LevelProgressionService
|
||||
}
|
||||
|
||||
$current = SchoolClass::query()->find($currentClassId);
|
||||
if (! $current) {
|
||||
if (!$current) {
|
||||
return null;
|
||||
}
|
||||
$currentName = (string) ($current->class_name ?? '');
|
||||
@@ -119,10 +118,9 @@ class LevelProgressionService
|
||||
return null;
|
||||
}
|
||||
$classId = ClassSection::getClassId($classSectionId);
|
||||
if (! $classId) {
|
||||
if (!$classId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->resolveByCurrentClassId((int) $classId);
|
||||
}
|
||||
|
||||
@@ -183,7 +181,6 @@ class LevelProgressionService
|
||||
if ($existing) {
|
||||
$existing->fill($data);
|
||||
$existing->save();
|
||||
|
||||
return $existing;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@ class BalancedSectionPlacementService
|
||||
return $scoreB <=> $scoreA;
|
||||
}
|
||||
$name = strcmp((string) ($a['student_name'] ?? ''), (string) ($b['student_name'] ?? ''));
|
||||
|
||||
return $name !== 0 ? $name : ((int) $a['student_id'] <=> (int) $b['student_id']);
|
||||
});
|
||||
}
|
||||
@@ -80,7 +79,6 @@ class BalancedSectionPlacementService
|
||||
for ($i = 1; $i <= $sectionCount; $i++) {
|
||||
$sizes[$i] = $base + ($i <= $remainder ? 1 : 0);
|
||||
}
|
||||
|
||||
return $sizes;
|
||||
}
|
||||
|
||||
@@ -100,7 +98,6 @@ class BalancedSectionPlacementService
|
||||
if ($cmp !== 0) {
|
||||
return $cmp;
|
||||
}
|
||||
|
||||
return $a['section_index'] <=> $b['section_index'];
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ use Illuminate\Support\Facades\DB;
|
||||
|
||||
class PlacementPoolBuilder
|
||||
{
|
||||
public function __construct(private ScoreBandClassifier $bands) {}
|
||||
public function __construct(private ScoreBandClassifier $bands)
|
||||
{
|
||||
}
|
||||
|
||||
public function build(string $fromSchoolYear, string $toSchoolYear, ?int $toGradeLevelId = null): array
|
||||
{
|
||||
@@ -36,39 +38,34 @@ class PlacementPoolBuilder
|
||||
$studentId = (int) $row->student_id;
|
||||
if ((int) ($row->is_active ?? 1) === 0) {
|
||||
$exceptions['withdrawn_or_inactive_student'][] = $studentId;
|
||||
|
||||
continue;
|
||||
}
|
||||
if ($this->alreadyPlaced($studentId, $toSchoolYear)) {
|
||||
$exceptions['duplicate_target_year_placement'][] = $studentId;
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! $this->isPromotedDecision((string) ($row->decision ?? ''))) {
|
||||
if (!$this->isPromotedDecision((string) ($row->decision ?? ''))) {
|
||||
$bucket = $row->decision === null ? 'missing_student_decision' : 'decision_conflicts_with_enrollment_request';
|
||||
$exceptions[$bucket][] = $studentId;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$score = $row->year_score !== null ? (float) $row->year_score : ($row->final_average !== null ? (float) $row->final_average : null);
|
||||
if ($score === null) {
|
||||
$exceptions['missing_student_decision'][] = $studentId;
|
||||
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$band = $this->bands->fromScore($score, true);
|
||||
} catch (\RuntimeException) {
|
||||
$exceptions['failed_retained'][] = $studentId;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$pool[] = [
|
||||
'student_id' => $studentId,
|
||||
'student_type' => 'returning',
|
||||
'student_name' => trim((string) $row->lastname.', '.(string) $row->firstname),
|
||||
'student_name' => trim((string) $row->lastname . ', ' . (string) $row->firstname),
|
||||
'source_decision_id' => $row->decision_id ? (int) $row->decision_id : null,
|
||||
'source_enrollment_id' => $row->enrollment_id ? (int) $row->enrollment_id : null,
|
||||
'final_score' => $score,
|
||||
@@ -101,19 +98,17 @@ class PlacementPoolBuilder
|
||||
$studentId = (int) $row->student_id;
|
||||
if ((int) ($row->is_active ?? 1) === 0) {
|
||||
$exceptions['withdrawn_or_inactive_student'][] = $studentId;
|
||||
|
||||
continue;
|
||||
}
|
||||
if ($this->alreadyPlaced($studentId, $toSchoolYear)) {
|
||||
$exceptions['duplicate_target_year_placement'][] = $studentId;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$pool[] = [
|
||||
'student_id' => $studentId,
|
||||
'student_type' => 'new',
|
||||
'student_name' => trim((string) $row->lastname.', '.(string) $row->firstname),
|
||||
'student_name' => trim((string) $row->lastname . ', ' . (string) $row->firstname),
|
||||
'source_decision_id' => null,
|
||||
'source_enrollment_id' => (int) $row->enrollment_id,
|
||||
'final_score' => null,
|
||||
@@ -182,7 +177,6 @@ class PlacementPoolBuilder
|
||||
private function isPromotedDecision(string $decision): bool
|
||||
{
|
||||
$normalized = strtolower(trim($decision));
|
||||
|
||||
return in_array($normalized, ['passed', 'pass', 'promoted', 'promote', 'eligible_to_continue'], true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ use RuntimeException;
|
||||
class PromotionSectionCapacityService
|
||||
{
|
||||
public const CONFIG_KEY = 'promotion.section_capacity';
|
||||
|
||||
public const DEFAULT_CAPACITY = 20;
|
||||
|
||||
public function capacity(): array
|
||||
@@ -24,7 +23,7 @@ class PromotionSectionCapacityService
|
||||
'fallback' => self::DEFAULT_CAPACITY,
|
||||
]);
|
||||
$value = self::DEFAULT_CAPACITY;
|
||||
} elseif (! ctype_digit((string) $raw)) {
|
||||
} elseif (!ctype_digit((string) $raw)) {
|
||||
throw new RuntimeException('promotion.section_capacity must be a positive integer.');
|
||||
} else {
|
||||
$value = (int) $raw;
|
||||
|
||||
@@ -14,7 +14,8 @@ class SectionPlacementPreviewService
|
||||
private PromotionSectionCapacityService $capacityService,
|
||||
private PlacementPoolBuilder $poolBuilder,
|
||||
private BalancedSectionPlacementService $placementService
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function createDraft(
|
||||
string $fromSchoolYear,
|
||||
@@ -118,12 +119,12 @@ class SectionPlacementPreviewService
|
||||
->where('is_event_only', 0)
|
||||
->exists();
|
||||
if ($duplicate) {
|
||||
throw new RuntimeException('Student '.$studentId.' already has a target-year class placement.');
|
||||
throw new RuntimeException('Student ' . $studentId . ' already has a target-year class placement.');
|
||||
}
|
||||
|
||||
$sectionId = $sectionMap[(int) $student->planned_section_index] ?? null;
|
||||
if ($sectionId === null) {
|
||||
throw new RuntimeException('Missing target section for planned section '.$student->planned_section_index.'.');
|
||||
throw new RuntimeException('Missing target section for planned section ' . $student->planned_section_index . '.');
|
||||
}
|
||||
|
||||
DB::table('student_class')->insert([
|
||||
@@ -132,7 +133,7 @@ class SectionPlacementPreviewService
|
||||
'is_event_only' => 0,
|
||||
'semester' => $semester,
|
||||
'school_year' => $batch->to_school_year,
|
||||
'description' => 'Finalized from section placement batch #'.$batch->getKey(),
|
||||
'description' => 'Finalized from section placement batch #' . $batch->getKey(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
'updated_by' => $userId,
|
||||
@@ -153,7 +154,7 @@ class SectionPlacementPreviewService
|
||||
|
||||
private function ensureSections(SectionPlacementBatch $batch, string $semester): array
|
||||
{
|
||||
if (! $batch->to_grade_level_id) {
|
||||
if (!$batch->to_grade_level_id) {
|
||||
throw new RuntimeException('to_grade_level_id is required to finalize placement sections.');
|
||||
}
|
||||
|
||||
@@ -175,7 +176,6 @@ class SectionPlacementPreviewService
|
||||
|
||||
if ($existing) {
|
||||
$map[$i] = (int) $existing;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -198,8 +198,7 @@ class SectionPlacementPreviewService
|
||||
private function sectionName(int $classId, string $suffix, string $schoolYear): string
|
||||
{
|
||||
$className = DB::table('classes')->where('id', $classId)->value('class_name');
|
||||
$base = $className ? (string) $className : ('Class '.$classId);
|
||||
|
||||
return $base.'-'.$suffix.' '.$schoolYear;
|
||||
$base = $className ? (string) $className : ('Class ' . $classId);
|
||||
return $base . '-' . $suffix . ' ' . $schoolYear;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,12 +24,13 @@ class PromotionEligibilityService
|
||||
private LevelProgressionService $progression,
|
||||
private PromotionStatusService $statusService,
|
||||
private PromotionAuditService $audit
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate eligibility for a single student.
|
||||
*
|
||||
* @param int|null $userId actor id for audit
|
||||
* @param int|null $userId actor id for audit
|
||||
*/
|
||||
public function evaluateStudent(
|
||||
int $studentId,
|
||||
@@ -37,7 +38,7 @@ class PromotionEligibilityService
|
||||
?int $userId = null
|
||||
): ?StudentPromotionRecord {
|
||||
$student = Student::query()->find($studentId);
|
||||
if (! $student) {
|
||||
if (!$student) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -82,7 +83,7 @@ class PromotionEligibilityService
|
||||
$summary = $this->emptySummary();
|
||||
foreach ($studentIds as $studentId) {
|
||||
$student = Student::query()->find($studentId);
|
||||
if (! $student) {
|
||||
if (!$student) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
@@ -131,7 +132,7 @@ class PromotionEligibilityService
|
||||
$summary = $this->emptySummary();
|
||||
foreach ($studentIds as $studentId) {
|
||||
$student = Student::query()->find($studentId);
|
||||
if (! $student) {
|
||||
if (!$student) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
@@ -213,7 +214,7 @@ class PromotionEligibilityService
|
||||
->first();
|
||||
$isNew = $existing === null;
|
||||
|
||||
$record = $existing ?: new StudentPromotionRecord;
|
||||
$record = $existing ?: new StudentPromotionRecord();
|
||||
$record->fill([
|
||||
'student_id' => $studentId,
|
||||
'parent_id' => (int) ($student->parent_id ?? 0) ?: null,
|
||||
@@ -287,7 +288,7 @@ class PromotionEligibilityService
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if (! $decision) {
|
||||
if (!$decision) {
|
||||
return [
|
||||
'passed' => null,
|
||||
'final_average' => null,
|
||||
@@ -331,14 +332,14 @@ class PromotionEligibilityService
|
||||
return [
|
||||
'passed' => null,
|
||||
'final_average' => $score,
|
||||
'notes' => 'student_decisions has unrecognized decision: '.$rawDecision,
|
||||
'notes' => 'student_decisions has unrecognized decision: ' . $rawDecision,
|
||||
'has_data' => false,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveStatusFromEvaluation(?bool $passed, bool $terminal, bool $hasData): string
|
||||
{
|
||||
if (! $hasData) {
|
||||
if (!$hasData) {
|
||||
return StudentPromotionRecord::STATUS_ON_HOLD;
|
||||
}
|
||||
if ($passed === false) {
|
||||
@@ -347,7 +348,6 @@ class PromotionEligibilityService
|
||||
if ($terminal) {
|
||||
return StudentPromotionRecord::STATUS_GRADUATED;
|
||||
}
|
||||
|
||||
return StudentPromotionRecord::STATUS_AWAITING_PARENT;
|
||||
}
|
||||
|
||||
@@ -380,17 +380,15 @@ class PromotionEligibilityService
|
||||
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)) {
|
||||
if (!preg_match('/^(\d{4})-(\d{4})$/', $current, $m)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ((int) $m[1] + 1).'-'.((int) $m[2] + 1);
|
||||
return ((int) $m[1] + 1) . '-' . ((int) $m[2] + 1);
|
||||
}
|
||||
|
||||
private function passThreshold(): float
|
||||
@@ -399,7 +397,6 @@ class PromotionEligibilityService
|
||||
if ($configured !== null && is_numeric($configured)) {
|
||||
return (float) $configured;
|
||||
}
|
||||
|
||||
return self::DEFAULT_PASS_THRESHOLD;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ class PromotionEnrollmentService
|
||||
public function __construct(
|
||||
private PromotionStatusService $statusService,
|
||||
private PromotionAuditService $audit
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists actionable promotion records for a parent (the parent
|
||||
@@ -53,9 +54,9 @@ class PromotionEnrollmentService
|
||||
|
||||
$studentIds = $records->pluck('student_id')->unique()->all();
|
||||
/** @var EloquentCollection<int,Student> $studentsCollection */
|
||||
$studentsCollection = ! empty($studentIds)
|
||||
$studentsCollection = !empty($studentIds)
|
||||
? Student::query()->whereIn('id', $studentIds)->get()
|
||||
: new EloquentCollection;
|
||||
: new EloquentCollection();
|
||||
$studentsById = $studentsCollection->keyBy('id');
|
||||
|
||||
$payload = $records->map(function (StudentPromotionRecord $record) use ($studentsById) {
|
||||
@@ -79,7 +80,7 @@ class PromotionEnrollmentService
|
||||
$this->assertActionable($record);
|
||||
|
||||
return DB::transaction(function () use ($record, $parentId, $userId) {
|
||||
if (! $record->enrollment_started_at) {
|
||||
if (!$record->enrollment_started_at) {
|
||||
$record->enrollment_started_at = now();
|
||||
}
|
||||
$record->enrollment_status = StudentPromotionRecord::ENROLLMENT_IN_PROGRESS;
|
||||
@@ -116,7 +117,7 @@ class PromotionEnrollmentService
|
||||
|
||||
$sanitized = [];
|
||||
foreach ($steps as $field => $value) {
|
||||
if (! in_array($field, self::ALLOWED_STEPS, true)) {
|
||||
if (!in_array($field, self::ALLOWED_STEPS, true)) {
|
||||
continue;
|
||||
}
|
||||
$sanitized[$field] = (bool) $value;
|
||||
@@ -138,7 +139,7 @@ class PromotionEnrollmentService
|
||||
return $record;
|
||||
}
|
||||
|
||||
if (! $record->enrollment_started_at) {
|
||||
if (!$record->enrollment_started_at) {
|
||||
$record->enrollment_started_at = now();
|
||||
}
|
||||
$record->enrollment_status = StudentPromotionRecord::ENROLLMENT_IN_PROGRESS;
|
||||
@@ -184,7 +185,7 @@ class PromotionEnrollmentService
|
||||
$this->assertParentOwns($record, $parentId);
|
||||
$this->assertActionable($record);
|
||||
|
||||
if (! $record->enrollmentChecklistComplete()) {
|
||||
if (!$record->enrollmentChecklistComplete()) {
|
||||
throw new RuntimeException('Enrollment checklist is incomplete.');
|
||||
}
|
||||
|
||||
@@ -293,12 +294,10 @@ class PromotionEnrollmentService
|
||||
if ($existing) {
|
||||
$existing->fill($payload);
|
||||
$existing->save();
|
||||
|
||||
return (int) $existing->getKey();
|
||||
}
|
||||
|
||||
$created = Enrollment::query()->create($payload);
|
||||
|
||||
return $created ? (int) $created->getKey() : null;
|
||||
}
|
||||
|
||||
@@ -308,7 +307,7 @@ class PromotionEnrollmentService
|
||||
'promotion_id' => (int) $record->getKey(),
|
||||
'student_id' => (int) $record->student_id,
|
||||
'student_name' => $student
|
||||
? trim((string) $student->firstname.' '.(string) $student->lastname)
|
||||
? trim((string) $student->firstname . ' ' . (string) $student->lastname)
|
||||
: null,
|
||||
'current_level' => $record->current_level_name,
|
||||
'promoted_level' => $record->promoted_level_name,
|
||||
@@ -344,13 +343,12 @@ class PromotionEnrollmentService
|
||||
true
|
||||
);
|
||||
});
|
||||
if (! $actionable) {
|
||||
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';
|
||||
|
||||
$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,
|
||||
@@ -374,7 +372,7 @@ class PromotionEnrollmentService
|
||||
private function assertActionable(StudentPromotionRecord $record): void
|
||||
{
|
||||
$actionable = StudentPromotionRecord::parentActionableStatuses();
|
||||
if (! in_array($record->promotion_status, $actionable, true)) {
|
||||
if (!in_array($record->promotion_status, $actionable, true)) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Promotion record is not actionable in status "%s".',
|
||||
$record->promotion_status
|
||||
@@ -385,10 +383,9 @@ class PromotionEnrollmentService
|
||||
private function guessNextSchoolYear(): ?string
|
||||
{
|
||||
$current = (string) (Configuration::getConfigValueByKey('school_year') ?? '');
|
||||
if (! preg_match('/^(\d{4})-(\d{4})$/', $current, $m)) {
|
||||
if (!preg_match('/^(\d{4})-(\d{4})$/', $current, $m)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ((int) $m[1] + 1).'-'.((int) $m[2] + 1);
|
||||
return ((int) $m[1] + 1) . '-' . ((int) $m[2] + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ class PromotionQueryService
|
||||
* parent_action_required?: bool,
|
||||
* per_page?: int|null,
|
||||
* } $filters
|
||||
*
|
||||
* @return array{
|
||||
* data: array<int,array<string,mixed>>,
|
||||
* total: int,
|
||||
@@ -67,12 +68,12 @@ class PromotionQueryService
|
||||
$studentIds = $records->pluck('student_id')->unique()->values()->all();
|
||||
$parentIds = $records->pluck('parent_id')->filter()->unique()->values()->all();
|
||||
|
||||
$studentsById = ! empty($studentIds)
|
||||
$studentsById = !empty($studentIds)
|
||||
? Student::query()->whereIn('id', $studentIds)->get()->keyBy('id')
|
||||
: new EloquentCollection;
|
||||
$parentsById = ! empty($parentIds)
|
||||
: new EloquentCollection();
|
||||
$parentsById = !empty($parentIds)
|
||||
? User::query()->whereIn('id', $parentIds)->get()->keyBy('id')
|
||||
: new EloquentCollection;
|
||||
: new EloquentCollection();
|
||||
|
||||
$rows = $records->map(function (StudentPromotionRecord $record) use ($studentsById, $parentsById) {
|
||||
return $this->presentAdminRow(
|
||||
@@ -116,7 +117,6 @@ class PromotionQueryService
|
||||
foreach ($rows as $row) {
|
||||
$counts[(string) $row->promotion_status] = (int) $row->total;
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
@@ -133,34 +133,34 @@ class PromotionQueryService
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<StudentPromotionRecord> $query
|
||||
* @param Builder<StudentPromotionRecord> $query
|
||||
*/
|
||||
private function applyFilters(Builder $query, array $filters): void
|
||||
{
|
||||
if (! empty($filters['status'])) {
|
||||
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)) {
|
||||
if (!empty($statuses)) {
|
||||
$query->whereIn('promotion_status', $statuses);
|
||||
}
|
||||
}
|
||||
if (! empty($filters['parent_action_required'])) {
|
||||
if (!empty($filters['parent_action_required'])) {
|
||||
$query->whereIn('promotion_status', StudentPromotionRecord::parentActionableStatuses());
|
||||
}
|
||||
if (! empty($filters['next_school_year'])) {
|
||||
if (!empty($filters['next_school_year'])) {
|
||||
$query->where('next_school_year', $filters['next_school_year']);
|
||||
}
|
||||
if (! empty($filters['current_school_year'])) {
|
||||
if (!empty($filters['current_school_year'])) {
|
||||
$query->where('current_school_year', $filters['current_school_year']);
|
||||
}
|
||||
if (! empty($filters['parent_id'])) {
|
||||
if (!empty($filters['parent_id'])) {
|
||||
$query->where('parent_id', (int) $filters['parent_id']);
|
||||
}
|
||||
if (! empty($filters['student_id'])) {
|
||||
if (!empty($filters['student_id'])) {
|
||||
$query->where('student_id', (int) $filters['student_id']);
|
||||
}
|
||||
if (! empty($filters['search'])) {
|
||||
$search = '%'.strtolower((string) $filters['search']).'%';
|
||||
if (!empty($filters['search'])) {
|
||||
$search = '%' . strtolower((string) $filters['search']) . '%';
|
||||
$query->whereIn('student_id', function ($sub) use ($search) {
|
||||
$sub->select('id')
|
||||
->from('students')
|
||||
@@ -182,12 +182,12 @@ class PromotionQueryService
|
||||
'promotion_id' => (int) $record->getKey(),
|
||||
'student_id' => (int) $record->student_id,
|
||||
'student_name' => $student
|
||||
? trim((string) $student->firstname.' '.(string) $student->lastname)
|
||||
? 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)
|
||||
? trim((string) $parent->firstname . ' ' . (string) $parent->lastname)
|
||||
: null,
|
||||
'parent_email' => $parent->email ?? null,
|
||||
'current_school_year' => $record->current_school_year,
|
||||
|
||||
@@ -19,7 +19,7 @@ use Illuminate\Support\Facades\Log;
|
||||
*/
|
||||
class PromotionReminderService
|
||||
{
|
||||
/** @var (callable(int, string, string, array):void)|null */
|
||||
/** @var (callable(int $userId, string $title, string $body, array $channels):void)|null */
|
||||
private $dispatcher;
|
||||
|
||||
public function __construct(
|
||||
@@ -40,7 +40,7 @@ class PromotionReminderService
|
||||
?string $message = null,
|
||||
array $channels = ['in_app', 'email']
|
||||
): PromotionReminderLog {
|
||||
if (! in_array($reminderType, PromotionReminderLog::ALLOWED_TYPES, true)) {
|
||||
if (!in_array($reminderType, PromotionReminderLog::ALLOWED_TYPES, true)) {
|
||||
$reminderType = PromotionReminderLog::TYPE_MANUAL;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ class PromotionReminderService
|
||||
'%s is eligible for promotion to %s.%s Please complete enrollment as soon as possible.',
|
||||
$studentName ?: 'Your child',
|
||||
$level,
|
||||
$deadline ? ' Deadline: '.$deadline.'.' : ''
|
||||
$deadline ? ' Deadline: ' . $deadline . '.' : ''
|
||||
);
|
||||
|
||||
$finalSubject = $subject ?: $defaultSubject;
|
||||
@@ -83,7 +83,7 @@ class PromotionReminderService
|
||||
$this->dispatch((int) $record->parent_id, $finalSubject, $finalMessage, $channels);
|
||||
}
|
||||
|
||||
if (! $record->parent_notified_at) {
|
||||
if (!$record->parent_notified_at) {
|
||||
$record->parent_notified_at = now();
|
||||
$record->save();
|
||||
$this->audit->logParentNotified($record, $userId, $reminderType);
|
||||
@@ -118,7 +118,6 @@ class PromotionReminderService
|
||||
$type = $this->resolveReminderType($record, $now);
|
||||
if ($type === null) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
$alreadySent = PromotionReminderLog::query()
|
||||
@@ -127,7 +126,6 @@ class PromotionReminderService
|
||||
->exists();
|
||||
if ($alreadySent) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
@@ -179,24 +177,22 @@ class PromotionReminderService
|
||||
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) {
|
||||
if (!$student) {
|
||||
return null;
|
||||
}
|
||||
$name = trim((string) ($student->firstname ?? '').' '.(string) ($student->lastname ?? ''));
|
||||
|
||||
$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) {
|
||||
if (!$this->dispatcher) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -15,7 +15,9 @@ use RuntimeException;
|
||||
*/
|
||||
class PromotionStatusService
|
||||
{
|
||||
public function __construct(private PromotionAuditService $audit) {}
|
||||
public function __construct(private PromotionAuditService $audit)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of allowed transitions from current → set of next statuses.
|
||||
@@ -78,11 +80,10 @@ class PromotionStatusService
|
||||
if ($from === $to) {
|
||||
return true;
|
||||
}
|
||||
if (! in_array($to, StudentPromotionRecord::ALL_STATUSES, true)) {
|
||||
if (!in_array($to, StudentPromotionRecord::ALL_STATUSES, true)) {
|
||||
return false;
|
||||
}
|
||||
$allowed = self::ALLOWED_TRANSITIONS[$from] ?? [];
|
||||
|
||||
return in_array($to, $allowed, true);
|
||||
}
|
||||
|
||||
@@ -97,7 +98,7 @@ class PromotionStatusService
|
||||
?int $userId = null,
|
||||
?string $notes = null
|
||||
): StudentPromotionRecord {
|
||||
if (! in_array($newStatus, StudentPromotionRecord::ALL_STATUSES, true)) {
|
||||
if (!in_array($newStatus, StudentPromotionRecord::ALL_STATUSES, true)) {
|
||||
throw new InvalidArgumentException(sprintf('Unknown promotion status "%s".', $newStatus));
|
||||
}
|
||||
|
||||
@@ -105,7 +106,7 @@ class PromotionStatusService
|
||||
if ($oldStatus === $newStatus) {
|
||||
return $record;
|
||||
}
|
||||
if (! $this->canTransition($oldStatus, $newStatus)) {
|
||||
if (!$this->canTransition($oldStatus, $newStatus)) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Transition from "%s" to "%s" is not allowed.',
|
||||
$oldStatus,
|
||||
@@ -134,7 +135,7 @@ class PromotionStatusService
|
||||
?int $userId = null,
|
||||
?string $notes = null
|
||||
): StudentPromotionRecord {
|
||||
if (! in_array($newStatus, StudentPromotionRecord::ALL_STATUSES, true)) {
|
||||
if (!in_array($newStatus, StudentPromotionRecord::ALL_STATUSES, true)) {
|
||||
throw new InvalidArgumentException(sprintf('Unknown promotion status "%s".', $newStatus));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user