fix api security issues and update pages issue
This commit is contained in:
@@ -584,6 +584,308 @@ class GradingBelowSixtyService
|
||||
$this->sendEmail($payload);
|
||||
}
|
||||
|
||||
public function listAllDecisions(string $schoolYear): array
|
||||
{
|
||||
$saved = StudentDecision::query()
|
||||
->where('school_year', $schoolYear)
|
||||
->get();
|
||||
|
||||
$savedMap = [];
|
||||
foreach ($saved as $row) {
|
||||
$studentId = (int) $row->student_id;
|
||||
if ($studentId > 0) {
|
||||
$savedMap[$studentId] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
$allScoreRows = DB::table('semester_scores as ss')
|
||||
->select([
|
||||
's.id as student_id',
|
||||
's.school_id',
|
||||
's.firstname',
|
||||
's.lastname',
|
||||
's.gender',
|
||||
'ss.class_section_id',
|
||||
'cs.class_section_name',
|
||||
DB::raw('LOWER(TRIM(ss.semester)) as sem_key'),
|
||||
'ss.semester_score',
|
||||
])
|
||||
->join('students as s', 's.id', '=', 'ss.student_id')
|
||||
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ss.class_section_id')
|
||||
->where('s.is_active', 1)
|
||||
->where('ss.school_year', $schoolYear)
|
||||
->whereRaw('LOWER(TRIM(ss.semester)) IN (?, ?)', ['fall', 'spring'])
|
||||
->whereNotNull('ss.semester_score')
|
||||
->orderBy('cs.class_section_name')
|
||||
->orderBy('s.lastname')
|
||||
->orderBy('s.firstname')
|
||||
->get();
|
||||
|
||||
$studentMap = [];
|
||||
foreach ($allScoreRows as $row) {
|
||||
$studentId = (int) ($row->student_id ?? 0);
|
||||
if ($studentId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($studentMap[$studentId])) {
|
||||
$studentMap[$studentId] = [
|
||||
'school_id' => $row->school_id,
|
||||
'firstname' => $row->firstname,
|
||||
'lastname' => $row->lastname,
|
||||
'gender' => $row->gender,
|
||||
'class_section_id' => (int) ($row->class_section_id ?? 0),
|
||||
'class_section_name' => $row->class_section_name,
|
||||
'fall_score' => null,
|
||||
'spring_score' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$semesterKey = strtolower(trim((string) ($row->sem_key ?? '')));
|
||||
$score = is_numeric($row->semester_score ?? null) ? (float) $row->semester_score : null;
|
||||
if ($semesterKey === 'fall') {
|
||||
$studentMap[$studentId]['fall_score'] = $score;
|
||||
} elseif ($semesterKey === 'spring') {
|
||||
$studentMap[$studentId]['spring_score'] = $score;
|
||||
}
|
||||
}
|
||||
|
||||
$belowRows = BelowSixtyDecision::query()
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester', 'year')
|
||||
->get();
|
||||
|
||||
$belowMap = [];
|
||||
foreach ($belowRows as $row) {
|
||||
$studentId = (int) ($row->student_id ?? 0);
|
||||
if ($studentId <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($belowMap[$studentId]) || trim((string) ($belowMap[$studentId]->decision ?? '')) === '') {
|
||||
$belowMap[$studentId] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($studentMap as $studentId => $info) {
|
||||
$fall = $info['fall_score'];
|
||||
$spring = $info['spring_score'];
|
||||
|
||||
if ($fall !== null && $spring !== null) {
|
||||
$yearScore = round(($fall + $spring) / 2, 2);
|
||||
} elseif ($fall !== null) {
|
||||
$yearScore = round((float) $fall, 2);
|
||||
} elseif ($spring !== null) {
|
||||
$yearScore = round((float) $spring, 2);
|
||||
} else {
|
||||
$yearScore = null;
|
||||
}
|
||||
|
||||
if (isset($savedMap[$studentId])) {
|
||||
$savedRow = $savedMap[$studentId];
|
||||
$decision = trim((string) ($savedRow->decision ?? ''));
|
||||
$source = trim((string) ($savedRow->source ?? 'pending'));
|
||||
$notes = (string) ($savedRow->notes ?? '');
|
||||
if (is_numeric($savedRow->year_score ?? null)) {
|
||||
$yearScore = round((float) $savedRow->year_score, 2);
|
||||
}
|
||||
} elseif ($yearScore !== null && $yearScore >= 60) {
|
||||
$decision = 'Pass';
|
||||
$source = 'auto';
|
||||
$notes = '';
|
||||
} elseif ($yearScore !== null && isset($belowMap[$studentId])) {
|
||||
$decision = trim((string) ($belowMap[$studentId]->decision ?? ''));
|
||||
$source = $decision !== '' ? 'manual' : 'pending';
|
||||
$notes = (string) ($belowMap[$studentId]->notes ?? '');
|
||||
} else {
|
||||
$decision = '';
|
||||
$source = 'pending';
|
||||
$notes = '';
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'student_id' => $studentId,
|
||||
'school_id' => $info['school_id'],
|
||||
'firstname' => $info['firstname'],
|
||||
'lastname' => $info['lastname'],
|
||||
'gender' => $info['gender'] ?? '',
|
||||
'class_section_id' => (int) ($info['class_section_id'] ?? 0),
|
||||
'class_section_name' => $info['class_section_name'],
|
||||
'fall_score' => $fall,
|
||||
'spring_score' => $spring,
|
||||
'year_score' => $yearScore,
|
||||
'decision' => $decision,
|
||||
'source' => $source,
|
||||
'notes' => $notes,
|
||||
'saved' => isset($savedMap[$studentId]),
|
||||
'is_trophy' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$rowsByClass = [];
|
||||
foreach ($rows as $index => $row) {
|
||||
$classSectionId = (int) ($row['class_section_id'] ?? 0);
|
||||
if ($classSectionId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$rowsByClass[$classSectionId][] = $index;
|
||||
}
|
||||
|
||||
foreach ($rowsByClass as $classIndexes) {
|
||||
$scores = [];
|
||||
foreach ($classIndexes as $rowIndex) {
|
||||
$yearScore = $rows[$rowIndex]['year_score'] ?? null;
|
||||
if (is_numeric($yearScore)) {
|
||||
$scores[] = (float) $yearScore;
|
||||
}
|
||||
}
|
||||
|
||||
$thresholdInfo = $this->calculateTrophyThreshold($scores, 75.0);
|
||||
$threshold = $thresholdInfo['threshold'];
|
||||
if ($threshold === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($classIndexes as $rowIndex) {
|
||||
$yearScore = $rows[$rowIndex]['year_score'] ?? null;
|
||||
$rows[$rowIndex]['is_trophy'] = is_numeric($yearScore) && (float) $yearScore >= $threshold;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'rows' => $rows,
|
||||
'generated' => !empty($savedMap),
|
||||
'school_year' => $schoolYear,
|
||||
];
|
||||
}
|
||||
|
||||
public function generateAllDecisions(string $schoolYear, ?int $userId): int
|
||||
{
|
||||
$allScoreRows = DB::table('semester_scores as ss')
|
||||
->select([
|
||||
's.id as student_id',
|
||||
's.firstname',
|
||||
's.lastname',
|
||||
'cs.class_section_name',
|
||||
DB::raw('LOWER(TRIM(ss.semester)) as sem_key'),
|
||||
'ss.semester_score',
|
||||
])
|
||||
->join('students as s', 's.id', '=', 'ss.student_id')
|
||||
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ss.class_section_id')
|
||||
->where('s.is_active', 1)
|
||||
->where('ss.school_year', $schoolYear)
|
||||
->whereRaw('LOWER(TRIM(ss.semester)) IN (?, ?)', ['fall', 'spring'])
|
||||
->whereNotNull('ss.semester_score')
|
||||
->get();
|
||||
|
||||
if ($allScoreRows->isEmpty()) {
|
||||
throw new RuntimeException('No semester scores found for this school year.');
|
||||
}
|
||||
|
||||
$studentMap = [];
|
||||
foreach ($allScoreRows as $row) {
|
||||
$studentId = (int) ($row->student_id ?? 0);
|
||||
if ($studentId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($studentMap[$studentId])) {
|
||||
$studentMap[$studentId] = [
|
||||
'firstname' => $row->firstname,
|
||||
'lastname' => $row->lastname,
|
||||
'class_section_name' => $row->class_section_name,
|
||||
'fall_score' => null,
|
||||
'spring_score' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$semesterKey = strtolower(trim((string) ($row->sem_key ?? '')));
|
||||
$score = is_numeric($row->semester_score ?? null) ? (float) $row->semester_score : null;
|
||||
if ($semesterKey === 'fall') {
|
||||
$studentMap[$studentId]['fall_score'] = $score;
|
||||
} elseif ($semesterKey === 'spring') {
|
||||
$studentMap[$studentId]['spring_score'] = $score;
|
||||
}
|
||||
}
|
||||
|
||||
$belowRows = BelowSixtyDecision::query()
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester', 'year')
|
||||
->get();
|
||||
$belowMap = [];
|
||||
foreach ($belowRows as $row) {
|
||||
$studentId = (int) ($row->student_id ?? 0);
|
||||
if ($studentId <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($belowMap[$studentId]) || trim((string) ($belowMap[$studentId]->decision ?? '')) === '') {
|
||||
$belowMap[$studentId] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
$existingRows = StudentDecision::query()
|
||||
->where('school_year', $schoolYear)
|
||||
->get();
|
||||
$existingMap = [];
|
||||
foreach ($existingRows as $row) {
|
||||
$studentId = (int) ($row->student_id ?? 0);
|
||||
if ($studentId > 0) {
|
||||
$existingMap[$studentId] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
$savedCount = 0;
|
||||
foreach ($studentMap as $studentId => $info) {
|
||||
$fall = $info['fall_score'];
|
||||
$spring = $info['spring_score'];
|
||||
if ($fall !== null && $spring !== null) {
|
||||
$yearScore = round(($fall + $spring) / 2, 2);
|
||||
} elseif ($fall !== null) {
|
||||
$yearScore = round((float) $fall, 2);
|
||||
} elseif ($spring !== null) {
|
||||
$yearScore = round((float) $spring, 2);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($yearScore >= 60) {
|
||||
$decision = 'Pass';
|
||||
$source = 'auto';
|
||||
$notes = null;
|
||||
} elseif (isset($belowMap[$studentId]) && trim((string) ($belowMap[$studentId]->decision ?? '')) !== '') {
|
||||
$decision = trim((string) $belowMap[$studentId]->decision);
|
||||
$source = 'manual';
|
||||
$notes = trim((string) ($belowMap[$studentId]->notes ?? ''));
|
||||
$notes = $notes !== '' ? $notes : null;
|
||||
} else {
|
||||
$decision = null;
|
||||
$source = 'pending';
|
||||
$notes = null;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'student_id' => $studentId,
|
||||
'school_year' => $schoolYear,
|
||||
'class_section_name' => $info['class_section_name'] ?? null,
|
||||
'year_score' => $yearScore,
|
||||
'decision' => $decision,
|
||||
'source' => $source,
|
||||
'notes' => $notes,
|
||||
'generated_by' => $userId,
|
||||
];
|
||||
|
||||
if (isset($existingMap[$studentId])) {
|
||||
$existingMap[$studentId]->fill($payload)->save();
|
||||
} else {
|
||||
StudentDecision::query()->create($payload);
|
||||
}
|
||||
|
||||
$savedCount++;
|
||||
}
|
||||
|
||||
return $savedCount;
|
||||
}
|
||||
|
||||
private function fetchBelowSixtyEmailRow(int $studentId, string $schoolYear, string $semester): array
|
||||
{
|
||||
$semesterKey = strtolower(trim($semester));
|
||||
@@ -866,6 +1168,108 @@ class GradingBelowSixtyService
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function calculateTrophyThreshold(array $scores, float $percentile = 75.0): array
|
||||
{
|
||||
$scores = array_values(array_filter(
|
||||
$scores,
|
||||
static fn ($value): bool => is_numeric($value) && $value !== null
|
||||
));
|
||||
$scores = array_map('floatval', $scores);
|
||||
sort($scores);
|
||||
|
||||
$count = count($scores);
|
||||
if ($count === 0) {
|
||||
return ['threshold' => null, 'winners' => 0, 'method' => 'empty'];
|
||||
}
|
||||
|
||||
$minWinners = 3;
|
||||
$maxWinners = max($minWinners, (int) floor($count * (1 - $percentile / 100)));
|
||||
|
||||
$threshold = $this->empiricalTrophyPercentile($scores, $percentile);
|
||||
$winners = $this->countScoresAtOrAbove($scores, $threshold);
|
||||
|
||||
if ($winners < $minWinners) {
|
||||
$target = min($minWinners, $count);
|
||||
$descending = array_reverse($scores);
|
||||
$threshold = $descending[$target - 1];
|
||||
$winners = $this->countScoresAtOrAbove($scores, $threshold);
|
||||
|
||||
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_reduced'];
|
||||
}
|
||||
|
||||
if ($winners <= $maxWinners) {
|
||||
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'empirical_percentile'];
|
||||
}
|
||||
|
||||
$result = $this->capTrophyThresholdByRank($scores, $maxWinners);
|
||||
if ($result['winners'] < $minWinners) {
|
||||
$target = min($minWinners, $count);
|
||||
$descending = array_reverse($scores);
|
||||
$threshold = $descending[$target - 1];
|
||||
$winners = $this->countScoresAtOrAbove($scores, $threshold);
|
||||
|
||||
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_after_cap'];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function capTrophyThresholdByRank(array $sortedScores, int $max): array
|
||||
{
|
||||
$descending = array_reverse($sortedScores);
|
||||
$threshold = $descending[$max - 1];
|
||||
$winners = $this->countScoresAtOrAbove($sortedScores, $threshold);
|
||||
|
||||
if ($winners <= $max) {
|
||||
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'capped_25pct'];
|
||||
}
|
||||
|
||||
$uniqueHigherScores = array_values(array_unique(array_filter(
|
||||
$sortedScores,
|
||||
static fn ($score): bool => $score > $threshold
|
||||
)));
|
||||
sort($uniqueHigherScores);
|
||||
|
||||
foreach ($uniqueHigherScores as $candidate) {
|
||||
$winnerCount = $this->countScoresAtOrAbove($sortedScores, $candidate);
|
||||
if ($winnerCount <= $max) {
|
||||
return ['threshold' => $candidate, 'winners' => $winnerCount, 'method' => 'capped_25pct'];
|
||||
}
|
||||
}
|
||||
|
||||
return ['threshold' => $sortedScores[0], 'winners' => count($sortedScores), 'method' => 'all_equal'];
|
||||
}
|
||||
|
||||
private function empiricalTrophyPercentile(array $sortedScores, float $percentile): float
|
||||
{
|
||||
$count = count($sortedScores);
|
||||
if ($count === 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
if ($count === 1) {
|
||||
return (float) $sortedScores[0];
|
||||
}
|
||||
|
||||
$rank = ($percentile / 100) * ($count - 1);
|
||||
$lowerIndex = (int) floor($rank);
|
||||
$upperIndex = (int) ceil($rank);
|
||||
$weight = $rank - $lowerIndex;
|
||||
|
||||
$lower = (float) $sortedScores[$lowerIndex];
|
||||
$upper = (float) $sortedScores[$upperIndex];
|
||||
|
||||
return $lower + (($upper - $lower) * $weight);
|
||||
}
|
||||
|
||||
private function countScoresAtOrAbove(array $scores, float $threshold): int
|
||||
{
|
||||
return count(array_filter(
|
||||
$scores,
|
||||
static fn ($score): bool => is_numeric($score) && (float) $score >= $threshold
|
||||
));
|
||||
}
|
||||
|
||||
private function buildDecisionEmailDetailsHtml(array $semesters): string
|
||||
{
|
||||
if (empty($semesters)) {
|
||||
|
||||
Reference in New Issue
Block a user