fix financial and certificates

This commit is contained in:
root
2026-06-05 01:51:08 -04:00
parent d28d11e2e5
commit ad968eaff7
94 changed files with 9654 additions and 214 deletions
@@ -0,0 +1,106 @@
<?php
namespace App\Services\Promotions\Placement;
class BalancedSectionPlacementService
{
public const ALGORITHM = 'score_band_balanced_v1';
public function assign(array $students, int $capacity): array
{
$total = count($students);
if ($total === 0) {
return [
'section_count' => 0,
'target_sizes' => [],
'sections' => [],
'assignments' => [],
];
}
$sectionCount = $total <= $capacity ? 1 : (int) ceil($total / $capacity);
$targetSizes = $this->targetSizes($total, $sectionCount);
$sections = [];
for ($i = 1; $i <= $sectionCount; $i++) {
$sections[$i] = [
'section_index' => $i,
'target_size' => $targetSizes[$i],
'total' => 0,
'bands' => ['A' => 0, 'B' => 0, 'C' => 0, 'D' => 0],
'students' => [],
];
}
$grouped = ['A' => [], 'B' => [], 'C' => [], 'D' => []];
foreach ($students as $student) {
$band = $student['score_band'] ?? 'D';
$grouped[$band][] = $student;
}
foreach (array_keys($grouped) as $band) {
usort($grouped[$band], function (array $a, array $b): int {
$scoreA = $a['final_score'] ?? $a['placement_score'] ?? -1;
$scoreB = $b['final_score'] ?? $b['placement_score'] ?? -1;
if ($scoreA !== $scoreB) {
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']);
});
}
$assignments = [];
$order = 1;
foreach (['A', 'B', 'C', 'D'] as $band) {
foreach ($grouped[$band] as $student) {
$sectionIndex = $this->selectSection($sections, $band);
$sections[$sectionIndex]['total']++;
$sections[$sectionIndex]['bands'][$band]++;
$sections[$sectionIndex]['students'][] = (int) $student['student_id'];
$student['planned_section_index'] = $sectionIndex;
$student['assignment_order'] = $order++;
$assignments[] = $student;
}
}
return [
'section_count' => $sectionCount,
'target_sizes' => $targetSizes,
'sections' => array_values($sections),
'assignments' => $assignments,
];
}
private function targetSizes(int $total, int $sectionCount): array
{
$base = intdiv($total, $sectionCount);
$remainder = $total % $sectionCount;
$sizes = [];
for ($i = 1; $i <= $sectionCount; $i++) {
$sizes[$i] = $base + ($i <= $remainder ? 1 : 0);
}
return $sizes;
}
private function selectSection(array $sections, string $band): int
{
$eligible = array_filter($sections, fn ($s) => $s['total'] < $s['target_size']);
if (empty($eligible)) {
$eligible = $sections;
}
uasort($eligible, function (array $a, array $b) use ($band): int {
$cmp = $a['total'] <=> $b['total'];
if ($cmp !== 0) {
return $cmp;
}
$cmp = ($a['bands'][$band] ?? 0) <=> ($b['bands'][$band] ?? 0);
if ($cmp !== 0) {
return $cmp;
}
return $a['section_index'] <=> $b['section_index'];
});
return (int) array_key_first($eligible);
}
}
@@ -0,0 +1,182 @@
<?php
namespace App\Services\Promotions\Placement;
use App\Models\StudentPromotionRecord;
use Illuminate\Support\Facades\DB;
class PlacementPoolBuilder
{
public function __construct(private ScoreBandClassifier $bands)
{
}
public function build(string $fromSchoolYear, string $toSchoolYear, ?int $toGradeLevelId = null): array
{
$pool = [];
$exceptions = $this->exceptionBuckets($fromSchoolYear, $toSchoolYear);
$returning = StudentPromotionRecord::query()
->from('student_promotion_records as r')
->join('students as s', 's.id', '=', 'r.student_id')
->leftJoin('student_decisions as d', function ($join) use ($fromSchoolYear) {
$join->on('d.student_id', '=', 'r.student_id')
->where('d.school_year', '=', $fromSchoolYear);
})
->where('r.current_school_year', $fromSchoolYear)
->where('r.next_school_year', $toSchoolYear)
->where('r.enrollment_status', StudentPromotionRecord::ENROLLMENT_COMPLETED)
->where('r.promotion_status', StudentPromotionRecord::STATUS_PROMOTED_AND_ENROLLED)
->when($toGradeLevelId !== null, fn ($q) => $q->where('r.promoted_level_id', $toGradeLevelId))
->selectRaw('r.*, s.firstname, s.lastname, s.is_active, d.id as decision_id, d.decision, d.year_score')
->orderBy('s.lastname')
->orderBy('s.firstname')
->orderBy('s.id')
->get();
foreach ($returning as $row) {
$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 ?? ''))) {
$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),
'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,
'placement_score' => null,
'score_band' => $band,
'placement_band_reason' => 'returning_student_final_score',
];
}
$newStudents = DB::table('enrollments as e')
->join('students as s', 's.id', '=', 'e.student_id')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'e.class_section_id')
->where('e.school_year', $toSchoolYear)
->where('s.is_new', 1)
->where('e.admission_status', 'accepted')
->whereIn('e.enrollment_status', ['enrolled', 'payment pending'])
->when($toGradeLevelId !== null, function ($q) use ($toGradeLevelId) {
$q->where(function ($inner) use ($toGradeLevelId) {
$inner->whereNull('e.class_section_id')
->orWhere('cs.class_id', $toGradeLevelId);
});
})
->selectRaw('e.id as enrollment_id, e.student_id, s.firstname, s.lastname, s.is_active')
->orderBy('s.lastname')
->orderBy('s.firstname')
->orderBy('s.id')
->get();
foreach ($newStudents as $row) {
$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),
'source_decision_id' => null,
'source_enrollment_id' => (int) $row->enrollment_id,
'final_score' => null,
'placement_score' => null,
'score_band' => 'D',
'placement_band_reason' => 'new_student_default_band',
];
}
return [
'pool' => $pool,
'exceptions' => array_map(fn ($ids) => array_values(array_unique($ids)), $exceptions),
];
}
private function exceptionBuckets(string $fromSchoolYear, string $toSchoolYear): array
{
$buckets = [
'passed_but_parent_enrollment_missing' => [],
'passed_and_parent_enrollment_completed' => [],
'failed_retained' => [],
'missing_student_decision' => [],
'pending_student_decision' => [],
'decision_conflicts_with_enrollment_request' => [],
'withdrawn_or_inactive_student' => [],
'duplicate_target_year_placement' => [],
];
$rows = DB::table('student_decisions as d')
->leftJoin('student_promotion_records as r', function ($join) use ($toSchoolYear) {
$join->on('r.student_id', '=', 'd.student_id')
->where('r.next_school_year', '=', $toSchoolYear);
})
->where('d.school_year', $fromSchoolYear)
->select('d.student_id', 'd.decision', 'r.enrollment_status')
->get();
foreach ($rows as $row) {
$decision = strtolower(trim((string) $row->decision));
$studentId = (int) $row->student_id;
if ($this->isPromotedDecision($decision)) {
if ($row->enrollment_status === StudentPromotionRecord::ENROLLMENT_COMPLETED) {
$buckets['passed_and_parent_enrollment_completed'][] = $studentId;
} else {
$buckets['passed_but_parent_enrollment_missing'][] = $studentId;
}
} elseif (in_array($decision, ['failed', 'fail', 'retained', 'repeat', 'repeated_level'], true)) {
$buckets['failed_retained'][] = $studentId;
} elseif ($decision === '' || $decision === 'pending') {
$buckets['pending_student_decision'][] = $studentId;
}
}
return $buckets;
}
private function alreadyPlaced(int $studentId, string $schoolYear): bool
{
return DB::table('student_class')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('is_event_only', 0)
->exists();
}
private function isPromotedDecision(string $decision): bool
{
$normalized = strtolower(trim($decision));
return in_array($normalized, ['passed', 'pass', 'promoted', 'promote', 'eligible_to_continue'], true);
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Services\Promotions\Placement;
use App\Models\Configuration;
use Illuminate\Support\Facades\Log;
use RuntimeException;
class PromotionSectionCapacityService
{
public const CONFIG_KEY = 'promotion.section_capacity';
public const DEFAULT_CAPACITY = 20;
public function capacity(): array
{
$raw = Configuration::getConfigValueByKey(self::CONFIG_KEY);
$usedDefault = false;
if ($raw === null || trim((string) $raw) === '') {
$usedDefault = true;
Log::warning('Missing promotion section capacity configuration; using fallback.', [
'config_key' => self::CONFIG_KEY,
'fallback' => self::DEFAULT_CAPACITY,
]);
$value = self::DEFAULT_CAPACITY;
} elseif (!ctype_digit((string) $raw)) {
throw new RuntimeException('promotion.section_capacity must be a positive integer.');
} else {
$value = (int) $raw;
}
if ($value <= 0) {
throw new RuntimeException('promotion.section_capacity must be greater than zero.');
}
return [
'key' => self::CONFIG_KEY,
'value' => $value,
'used_default' => $usedDefault,
'warnings' => $usedDefault ? ['promotion.section_capacity missing; fallback capacity 20 used.'] : [],
];
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Services\Promotions\Placement;
use RuntimeException;
class ScoreBandClassifier
{
public function fromScore(?float $score, bool $allowNull = false): ?string
{
if ($score === null) {
return $allowNull ? null : 'D';
}
if ($score >= 90.0 && $score <= 100.0) {
return 'A';
}
if ($score >= 80.0) {
return 'B';
}
if ($score >= 70.0) {
return 'C';
}
if ($score >= 60.0) {
return 'D';
}
throw new RuntimeException('Scores below 60 are not eligible for automatic placement.');
}
}
@@ -0,0 +1,204 @@
<?php
namespace App\Services\Promotions\Placement;
use App\Models\Configuration;
use App\Models\SectionPlacementBatch;
use App\Models\SectionPlacementBatchStudent;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class SectionPlacementPreviewService
{
public function __construct(
private PromotionSectionCapacityService $capacityService,
private PlacementPoolBuilder $poolBuilder,
private BalancedSectionPlacementService $placementService
) {
}
public function createDraft(
string $fromSchoolYear,
string $toSchoolYear,
?int $fromGradeLevelId = null,
?int $toGradeLevelId = null,
?int $createdBy = null
): SectionPlacementBatch {
$capacityInfo = $this->capacityService->capacity();
$poolResult = $this->poolBuilder->build($fromSchoolYear, $toSchoolYear, $toGradeLevelId);
$placement = $this->placementService->assign($poolResult['pool'], (int) $capacityInfo['value']);
$snapshot = [
'capacity' => $capacityInfo,
'target_section_sizes' => $placement['target_sizes'],
'sections' => $placement['sections'],
'excluded_students' => $poolResult['exceptions'],
'warnings' => $capacityInfo['warnings'],
'new_students_defaulted_to_d_band' => array_values(array_map(
fn ($s) => $s['student_id'],
array_filter($placement['assignments'], fn ($s) => ($s['placement_band_reason'] ?? null) === 'new_student_default_band')
)),
];
return DB::transaction(function () use ($fromSchoolYear, $toSchoolYear, $fromGradeLevelId, $toGradeLevelId, $createdBy, $capacityInfo, $placement, $snapshot) {
$batch = SectionPlacementBatch::query()->create([
'from_school_year' => $fromSchoolYear,
'to_school_year' => $toSchoolYear,
'from_grade_level_id' => $fromGradeLevelId,
'to_grade_level_id' => $toGradeLevelId,
'section_capacity_used' => (int) $capacityInfo['value'],
'total_students' => count($placement['assignments']),
'section_count' => (int) $placement['section_count'],
'algorithm' => BalancedSectionPlacementService::ALGORITHM,
'configuration_snapshot_json' => json_encode([
PromotionSectionCapacityService::CONFIG_KEY => (int) $capacityInfo['value'],
'placement.algorithm' => BalancedSectionPlacementService::ALGORITHM,
]),
'preview_snapshot_json' => json_encode($snapshot),
'created_by' => $createdBy,
'status' => SectionPlacementBatch::STATUS_DRAFT,
]);
foreach ($placement['assignments'] as $assignment) {
SectionPlacementBatchStudent::query()->create([
'batch_id' => $batch->getKey(),
'student_id' => $assignment['student_id'],
'student_type' => $assignment['student_type'],
'source_decision_id' => $assignment['source_decision_id'],
'source_enrollment_id' => $assignment['source_enrollment_id'],
'final_score' => $assignment['final_score'],
'placement_score' => $assignment['placement_score'],
'score_band' => $assignment['score_band'],
'placement_band_reason' => $assignment['placement_band_reason'],
'planned_section_index' => $assignment['planned_section_index'],
'assignment_order' => $assignment['assignment_order'],
'was_override' => false,
'created_at' => now(),
]);
}
return $batch->refresh();
});
}
public function previewPayload(SectionPlacementBatch $batch): array
{
return [
'batch' => $batch->toArray(),
'preview' => $batch->preview_snapshot_json ? json_decode((string) $batch->preview_snapshot_json, true) : null,
'students' => $batch->students()->orderBy('planned_section_index')->orderBy('assignment_order')->get()->toArray(),
];
}
public function finalize(int $batchId, ?int $userId = null): SectionPlacementBatch
{
$batch = SectionPlacementBatch::query()->findOrFail($batchId);
if ($batch->status !== SectionPlacementBatch::STATUS_DRAFT) {
throw new RuntimeException('Only draft placement batches can be finalized.');
}
if ((int) $batch->total_students !== $batch->students()->count()) {
throw new RuntimeException('Draft batch student count does not match batch total.');
}
return DB::transaction(function () use ($batch, $userId) {
$semester = Configuration::getConfigValueByKey('semester') ?: 'Fall';
$sectionMap = $this->ensureSections($batch, $semester);
$seen = [];
$students = $batch->students()->orderBy('assignment_order')->get();
foreach ($students as $student) {
$studentId = (int) $student->student_id;
if (isset($seen[$studentId])) {
throw new RuntimeException('Student appears more than once in placement batch.');
}
$seen[$studentId] = true;
$duplicate = DB::table('student_class')
->where('student_id', $studentId)
->where('school_year', $batch->to_school_year)
->where('is_event_only', 0)
->exists();
if ($duplicate) {
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 . '.');
}
DB::table('student_class')->insert([
'student_id' => $studentId,
'class_section_id' => $sectionId,
'is_event_only' => 0,
'semester' => $semester,
'school_year' => $batch->to_school_year,
'description' => 'Finalized from section placement batch #' . $batch->getKey(),
'created_at' => now(),
'updated_at' => now(),
'updated_by' => $userId,
]);
$student->assigned_section_id = $sectionId;
$student->save();
}
$batch->status = SectionPlacementBatch::STATUS_FINALIZED;
$batch->finalized_by = $userId;
$batch->finalized_at = now();
$batch->save();
return $batch->refresh();
});
}
private function ensureSections(SectionPlacementBatch $batch, string $semester): array
{
if (!$batch->to_grade_level_id) {
throw new RuntimeException('to_grade_level_id is required to finalize placement sections.');
}
$map = [];
$classId = (int) $batch->to_grade_level_id;
$sectionCount = (int) $batch->section_count;
$maxSectionId = (int) DB::table('classSection')->max('class_section_id');
$letters = range('A', 'Z');
for ($i = 1; $i <= $sectionCount; $i++) {
$name = $this->sectionName($classId, $letters[$i - 1] ?? (string) $i, $batch->to_school_year);
$existing = DB::table('classSection')
->where('class_id', $classId)
->where('class_section_name', $name)
->where(function ($q) use ($batch) {
$q->where('school_year', $batch->to_school_year)->orWhereNull('school_year');
})
->value('class_section_id');
if ($existing) {
$map[$i] = (int) $existing;
continue;
}
$sectionId = ++$maxSectionId;
DB::table('classSection')->insert([
'class_id' => $classId,
'class_section_id' => $sectionId,
'class_section_name' => $name,
'semester' => $semester,
'school_year' => $batch->to_school_year,
'created_at' => now(),
'updated_at' => now(),
]);
$map[$i] = $sectionId;
}
return $map;
}
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;
}
}
@@ -281,50 +281,59 @@ class PromotionEligibilityService
*/
private function loadAcademicResult(int $studentId, string $schoolYear): array
{
$rows = DB::table('semester_scores')
->select('semester', 'semester_score')
$decision = DB::table('student_decisions')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->get();
->orderByDesc('updated_at')
->orderByDesc('id')
->first();
$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) {
if (!$decision) {
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',
'notes' => 'Missing student_decisions record for promotion eligibility',
'has_data' => false,
];
}
$rawDecision = strtolower(trim((string) ($decision->decision ?? '')));
$score = $decision->year_score !== null ? round((float) $decision->year_score, 2) : null;
if (in_array($rawDecision, ['passed', 'pass', 'promoted', 'promote', 'eligible_to_continue'], true)) {
return [
'passed' => true,
'final_average' => $score,
'notes' => $score !== null
? sprintf('student_decisions marked promoted with score %.2f', $score)
: 'student_decisions marked promoted without a year_score',
'has_data' => true,
];
}
if (in_array($rawDecision, ['failed', 'fail', 'retained', 'repeat', 'repeated_level'], true)) {
return [
'passed' => false,
'final_average' => $score,
'notes' => 'student_decisions marked failed/retained',
'has_data' => true,
];
}
if ($rawDecision === '' || $rawDecision === 'pending' || $rawDecision === 'manual review' || $rawDecision === 'manual_review') {
return [
'passed' => null,
'final_average' => $score,
'notes' => 'student_decisions is pending or requires manual review',
'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,
'passed' => null,
'final_average' => $score,
'notes' => 'student_decisions has unrecognized decision: ' . $rawDecision,
'has_data' => false,
];
}