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,647 @@
<?php
namespace App\Services\Administrator\Trophy;
use App\Models\Configuration;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* Ports the legacy CodeIgniter trophy projection + winner reports to
* Laravel JSON payloads for the SPA/admin API.
*/
class TrophyReportService
{
public function projection(?string $schoolYear = null, ?float $percentile = null): array
{
$selectedYear = $this->resolveSchoolYear($schoolYear);
$selectedPercentile = $this->sanitizePercentile($percentile);
$sections = $this->sectionMap($this->studentRows($selectedYear));
$classResults = [];
foreach ($sections as $sectionId => $section) {
$students = $this->sortByScore($section['students'], 'fall_score');
$scores = array_column($students, 'fall_score');
$calc = $this->calculateThreshold($scores, $selectedPercentile);
$threshold = $calc['threshold'];
$students = array_map(function (array $student) use ($threshold): array {
$student['projected_trophy'] = $threshold !== null
&& $student['fall_score'] !== null
&& $student['fall_score'] >= $threshold;
return $student;
}, $students);
$scoredCount = count(array_filter(
array_column($students, 'fall_score'),
static fn ($score): bool => $score !== null
));
[$boys, $girls] = $this->splitByBinaryGender($students);
$trophyBoys = array_values(array_filter($boys, static fn (array $student): bool => ! empty($student['projected_trophy'])));
$trophyGirls = array_values(array_filter($girls, static fn (array $student): bool => ! empty($student['projected_trophy'])));
$studentCount = count($students);
$classResults[] = [
'section_id' => $sectionId,
'section_name' => $section['name'],
'students' => $students,
'threshold' => $threshold,
'trophy_count' => $calc['winners'],
'student_count' => $studentCount,
'scored_count' => $scoredCount,
'method' => $calc['method'],
'boys' => count($boys),
'girls' => count($girls),
'trophy_boys' => count($trophyBoys),
'trophy_girls' => count($trophyGirls),
'pct_boys' => $studentCount > 0 ? round(count($boys) / $studentCount * 100) : 0,
'pct_girls' => $studentCount > 0 ? round(count($girls) / $studentCount * 100) : 0,
'pct_trophy_boys' => count($boys) > 0 ? round(count($trophyBoys) / count($boys) * 100) : 0,
'pct_trophy_girls' => count($girls) > 0 ? round(count($trophyGirls) / count($girls) * 100) : 0,
'pct_trophy_total' => $studentCount > 0 ? round($calc['winners'] / $studentCount * 100) : 0,
];
}
return [
'selected_year' => $selectedYear,
'selected_percentile' => $selectedPercentile,
'years' => $this->schoolYears(),
'class_results' => $classResults,
'summary' => $this->projectionSummary($classResults),
];
}
public function winners(?string $schoolYear = null, ?float $percentile = null): array
{
$selectedYear = $this->resolveSchoolYear($schoolYear);
$selectedPercentile = $this->sanitizePercentile($percentile);
$sections = $this->sectionMap($this->studentRows($selectedYear));
$classResults = [];
foreach ($sections as $sectionId => $section) {
$students = $this->sortByScore($section['students'], 'fall_score');
$calc = $this->calculateThreshold(array_column($students, 'fall_score'), $selectedPercentile);
$threshold = $calc['threshold'];
$winners = array_values(array_filter($students, static function (array $student) use ($threshold): bool {
return $threshold !== null
&& $student['fall_score'] !== null
&& $student['fall_score'] >= $threshold;
}));
if ($winners === []) {
continue;
}
[$boys, $girls] = $this->splitByBinaryGender($students);
[$winnerBoys, $winnerGirls] = $this->splitByBinaryGender($winners);
$studentCount = count($students);
$classResults[] = [
'section_id' => $sectionId,
'section_name' => $section['name'],
'threshold' => $threshold,
'winners' => $winners,
'student_count' => $studentCount,
'boys' => count($boys),
'girls' => count($girls),
'trophy_boys' => count($winnerBoys),
'trophy_girls' => count($winnerGirls),
'pct_boys' => $studentCount > 0 ? round(count($boys) / $studentCount * 100) : 0,
'pct_girls' => $studentCount > 0 ? round(count($girls) / $studentCount * 100) : 0,
'pct_trophy_boys' => count($boys) > 0 ? round(count($winnerBoys) / count($boys) * 100) : 0,
'pct_trophy_girls' => count($girls) > 0 ? round(count($winnerGirls) / count($girls) * 100) : 0,
'pct_trophy_total' => $studentCount > 0 ? round(count($winners) / $studentCount * 100) : 0,
];
}
return [
'selected_year' => $selectedYear,
'selected_percentile' => $selectedPercentile,
'years' => $this->schoolYears(),
'class_results' => $classResults,
'summary' => $this->winnersSummary($classResults),
];
}
public function final(?string $schoolYear = null, ?float $percentile = null): array
{
$selectedYear = $this->resolveSchoolYear($schoolYear);
$selectedPercentile = $this->sanitizePercentile($percentile);
$sections = $this->sectionMap($this->studentRows($selectedYear));
$classResults = [];
$allStudents = [];
$passStudents = [];
$winnerStudents = [];
$winnerStickers = [];
foreach ($sections as $sectionId => $section) {
$students = $section['students'];
$fallCalc = $this->calculateThreshold(array_column($students, 'fall_score'), $selectedPercentile);
$yearCalc = $this->calculateThreshold(array_column($students, 'year_score'), $selectedPercentile);
$fallThreshold = $fallCalc['threshold'];
$yearThreshold = $yearCalc['threshold'];
$annotated = array_map(function (array $student) use ($fallThreshold, $yearThreshold): array {
$predicted = $fallThreshold !== null
&& $student['fall_score'] !== null
&& $student['fall_score'] >= $fallThreshold;
$actual = $yearThreshold !== null
&& $student['year_score'] !== null
&& $student['year_score'] >= $yearThreshold;
$status = match (true) {
$predicted && $actual => 'confirmed',
! $predicted && $actual => 'surprise',
$predicted && ! $actual => 'missed',
default => 'none',
};
return $student + compact('predicted', 'actual', 'status');
}, $students);
$annotated = $this->sortByScore($annotated, 'year_score');
foreach ($annotated as $student) {
$allStudents[] = $student;
if ($student['year_score'] !== null && $student['year_score'] >= 60.0) {
$passStudents[] = $student;
}
if (! empty($student['actual'])) {
$winnerStudents[] = $student;
$winnerStickers[] = [
'name' => $student['name'],
'section' => $section['name'],
];
}
}
$confirmed = count(array_filter($annotated, static fn (array $student): bool => $student['status'] === 'confirmed'));
$surprises = count(array_filter($annotated, static fn (array $student): bool => $student['status'] === 'surprise'));
$missed = count(array_filter($annotated, static fn (array $student): bool => $student['status'] === 'missed'));
$predictedCount = count(array_filter($annotated, static fn (array $student): bool => ! empty($student['predicted'])));
$actualCount = count(array_filter($annotated, static fn (array $student): bool => ! empty($student['actual'])));
$studentCount = count($annotated);
$classResults[] = [
'section_id' => $sectionId,
'section_name' => $section['name'],
'students' => $annotated,
'student_count' => $studentCount,
'fall_threshold' => $fallThreshold,
'year_threshold' => $yearThreshold,
'predicted_count' => $predictedCount,
'actual_count' => $actualCount,
'confirmed' => $confirmed,
'surprises' => $surprises,
'missed' => $missed,
'accuracy' => $predictedCount > 0 ? round($confirmed / $predictedCount * 100) : ($actualCount === 0 ? 100 : 0),
];
}
return [
'selected_year' => $selectedYear,
'selected_percentile' => $selectedPercentile,
'years' => $this->schoolYears(),
'class_results' => $classResults,
'summary' => $this->finalSummary($classResults),
'winner_gender_summary' => $this->genderBreakdown($winnerStudents),
'all_gender_summary' => $this->genderBreakdown($allStudents),
'pass_gender_summary' => $this->genderBreakdown($passStudents),
'winner_stickers' => array_values(array_filter($winnerStickers, static fn (array $sticker): bool => trim((string) ($sticker['name'] ?? '')) !== '')),
];
}
/**
* @param array<int, float|int|string|null> $scores
* @return array{threshold: float|null, winners: int, method: string}
*/
public function calculateThreshold(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->empiricalPercentile($scores, $percentile);
$winners = $this->countAtOrAbove($scores, $threshold);
if ($winners < $minWinners) {
$target = min($minWinners, $count);
$descending = array_reverse($scores);
$threshold = $descending[$target - 1];
$winners = $this->countAtOrAbove($scores, $threshold);
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_reduced'];
}
if ($winners <= $maxWinners) {
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'empirical_percentile'];
}
$result = $this->capByRank($scores, $maxWinners);
if ($result['winners'] < $minWinners) {
$target = min($minWinners, $count);
$descending = array_reverse($scores);
$threshold = $descending[$target - 1];
$winners = $this->countAtOrAbove($scores, $threshold);
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_after_cap'];
}
return $result;
}
private function resolveSchoolYear(?string $schoolYear): string
{
$selected = trim((string) $schoolYear);
if ($selected !== '') {
return $selected;
}
return (string) (Configuration::getConfig('school_year') ?? '');
}
private function sanitizePercentile(?float $percentile): float
{
$value = $percentile ?? 75.0;
return max(1.0, min(99.0, $value));
}
/**
* @return list<string>
*/
private function schoolYears(): array
{
return DB::table('student_class')
->select('school_year')
->distinct()
->orderBy('school_year', 'DESC')
->pluck('school_year')
->filter(static fn ($value): bool => $value !== null && trim((string) $value) !== '')
->map(static fn ($value): string => (string) $value)
->values()
->all();
}
/**
* @return \Illuminate\Support\Collection<int, array<string, mixed>>
*/
private function studentRows(string $schoolYear): Collection
{
$fallScores = DB::table('semester_scores')
->select(
'student_id',
'class_section_id',
DB::raw('MAX(semester_score) AS fall_score')
)
->where('school_year', $schoolYear)
->whereRaw('LOWER(semester) = ?', ['fall'])
->groupBy('student_id', 'class_section_id');
$springScores = DB::table('semester_scores')
->select(
'student_id',
'class_section_id',
DB::raw('MAX(semester_score) AS spring_score')
)
->where('school_year', $schoolYear)
->whereRaw('LOWER(semester) = ?', ['spring'])
->groupBy('student_id', 'class_section_id');
return DB::table('student_class as sc')
->select(
'sc.student_id',
'sc.class_section_id',
'cs.class_section_name',
's.firstname',
's.lastname',
's.school_id',
's.gender',
DB::raw('MAX(sf.fall_score) AS fall_score'),
DB::raw('MAX(sp.spring_score) AS spring_score')
)
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
->leftJoin('students as s', 's.id', '=', 'sc.student_id')
->leftJoinSub($fallScores, 'sf', function ($join): void {
$join->on('sf.student_id', '=', 'sc.student_id')
->on('sf.class_section_id', '=', 'sc.class_section_id');
})
->leftJoinSub($springScores, 'sp', function ($join): void {
$join->on('sp.student_id', '=', 'sc.student_id')
->on('sp.class_section_id', '=', 'sc.class_section_id');
})
->where('sc.school_year', $schoolYear)
->groupBy(
'sc.student_id',
'sc.class_section_id',
'cs.class_section_name',
's.firstname',
's.lastname',
's.school_id',
's.gender'
)
->orderBy('cs.class_section_name', 'ASC')
->orderBy('s.firstname', 'ASC')
->orderBy('s.lastname', 'ASC')
->get()
->map(function (object $row): array {
$student = (array) $row;
$fall = is_numeric($student['fall_score'] ?? null) ? round((float) $student['fall_score'], 1) : null;
$spring = is_numeric($student['spring_score'] ?? null) ? round((float) $student['spring_score'], 1) : null;
$year = ($fall !== null && $spring !== null)
? round(($fall + $spring) / 2, 1)
: ($fall ?? $spring);
$firstname = trim((string) ($student['firstname'] ?? ''));
$lastname = trim((string) ($student['lastname'] ?? ''));
$name = trim($firstname.' '.$lastname);
return [
'student_id' => (int) ($student['student_id'] ?? 0),
'class_section_id' => (int) ($student['class_section_id'] ?? 0),
'section_name' => (string) ($student['class_section_name'] ?? ''),
'name' => $name !== '' ? $name : 'Student #'.($student['student_id'] ?? ''),
'firstname' => $firstname !== '' ? $firstname : null,
'lastname' => $lastname !== '' ? $lastname : null,
'school_id' => $student['school_id'] ?? null,
'gender' => (string) ($student['gender'] ?? ''),
'fall_score' => $fall,
'spring_score' => $spring,
'year_score' => $year,
];
});
}
/**
* @param \Illuminate\Support\Collection<int, array<string, mixed>> $rows
* @return array<int, array{name: string, students: list<array<string, mixed>>}>
*/
private function sectionMap(Collection $rows): array
{
$sections = [];
foreach ($rows as $row) {
$sectionId = (int) ($row['class_section_id'] ?? 0);
$name = trim((string) ($row['section_name'] ?? '')) ?: 'Section '.$sectionId;
if (! isset($sections[$sectionId])) {
$sections[$sectionId] = [
'name' => $name,
'students' => [],
];
}
unset($row['section_name']);
$sections[$sectionId]['students'][] = $row;
}
return $sections;
}
/**
* @param list<array<string, mixed>> $students
* @return list<array<string, mixed>>
*/
private function sortByScore(array $students, string $scoreKey): array
{
usort($students, static function (array $left, array $right) use ($scoreKey): int {
$leftScore = $left[$scoreKey] ?? null;
$rightScore = $right[$scoreKey] ?? null;
if ($leftScore === null && $rightScore === null) {
return strcmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
}
if ($leftScore === null) {
return 1;
}
if ($rightScore === null) {
return -1;
}
return $rightScore <=> $leftScore ?: strcmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
});
return $students;
}
/**
* @param list<array<string, mixed>> $students
* @return array{0: list<array<string, mixed>>, 1: list<array<string, mixed>>}
*/
private function splitByBinaryGender(array $students): array
{
$boys = array_values(array_filter($students, fn (array $student): bool => $this->genderKey($student['gender'] ?? '') === 'boys'));
$girls = array_values(array_filter($students, fn (array $student): bool => $this->genderKey($student['gender'] ?? '') === 'girls'));
return [$boys, $girls];
}
/**
* @param list<array<string, mixed>> $classResults
* @return array<string, int|float>
*/
private function projectionSummary(array $classResults): array
{
$totalStudents = array_sum(array_column($classResults, 'student_count'));
$totalScored = array_sum(array_column($classResults, 'scored_count'));
$totalTrophies = array_sum(array_column($classResults, 'trophy_count'));
$totalBoys = array_sum(array_column($classResults, 'boys'));
$totalGirls = array_sum(array_column($classResults, 'girls'));
$totalTrophyBoys = array_sum(array_column($classResults, 'trophy_boys'));
$totalTrophyGirls = array_sum(array_column($classResults, 'trophy_girls'));
return [
'classes' => count($classResults),
'students' => $totalStudents,
'scored' => $totalScored,
'trophies' => $totalTrophies,
'boys' => $totalBoys,
'girls' => $totalGirls,
'trophy_boys' => $totalTrophyBoys,
'trophy_girls' => $totalTrophyGirls,
'pct_scored' => $totalStudents > 0 ? round($totalScored / $totalStudents * 100) : 0,
'pct_boys' => $totalStudents > 0 ? round($totalBoys / $totalStudents * 100) : 0,
'pct_girls' => $totalStudents > 0 ? round($totalGirls / $totalStudents * 100) : 0,
'pct_trophies' => $totalStudents > 0 ? round($totalTrophies / $totalStudents * 100) : 0,
'pct_trophy_boys' => $totalBoys > 0 ? round($totalTrophyBoys / $totalBoys * 100) : 0,
'pct_trophy_girls' => $totalGirls > 0 ? round($totalTrophyGirls / $totalGirls * 100) : 0,
];
}
/**
* @param list<array<string, mixed>> $classResults
* @return array<string, int|float>
*/
private function winnersSummary(array $classResults): array
{
$totalWinners = array_sum(array_map(static fn (array $classResult): int => count($classResult['winners'] ?? []), $classResults));
$totalStudents = array_sum(array_column($classResults, 'student_count'));
$totalBoys = array_sum(array_column($classResults, 'boys'));
$totalGirls = array_sum(array_column($classResults, 'girls'));
$totalTrophyBoys = array_sum(array_column($classResults, 'trophy_boys'));
$totalTrophyGirls = array_sum(array_column($classResults, 'trophy_girls'));
return [
'classes' => count($classResults),
'students' => $totalStudents,
'winners' => $totalWinners,
'boys' => $totalBoys,
'girls' => $totalGirls,
'trophy_boys' => $totalTrophyBoys,
'trophy_girls' => $totalTrophyGirls,
'pct_winners' => $totalStudents > 0 ? round($totalWinners / $totalStudents * 100) : 0,
'pct_boys' => $totalStudents > 0 ? round($totalBoys / $totalStudents * 100) : 0,
'pct_girls' => $totalStudents > 0 ? round($totalGirls / $totalStudents * 100) : 0,
'pct_trophy_boys' => $totalBoys > 0 ? round($totalTrophyBoys / $totalBoys * 100) : 0,
'pct_trophy_girls' => $totalGirls > 0 ? round($totalTrophyGirls / $totalGirls * 100) : 0,
];
}
/**
* @param list<array<string, mixed>> $classResults
* @return array<string, int|float>
*/
private function finalSummary(array $classResults): array
{
$totalStudents = array_sum(array_column($classResults, 'student_count'));
$totalPredicted = array_sum(array_column($classResults, 'predicted_count'));
$totalActual = array_sum(array_column($classResults, 'actual_count'));
$totalConfirmed = array_sum(array_column($classResults, 'confirmed'));
$totalSurprises = array_sum(array_column($classResults, 'surprises'));
$totalMissed = array_sum(array_column($classResults, 'missed'));
$overallAccuracy = $totalPredicted > 0 ? round($totalConfirmed / $totalPredicted * 100) : ($totalActual === 0 ? 100 : 0);
return [
'classes' => count($classResults),
'students' => $totalStudents,
'predicted' => $totalPredicted,
'actual' => $totalActual,
'confirmed' => $totalConfirmed,
'surprises' => $totalSurprises,
'missed' => $totalMissed,
'accuracy' => $overallAccuracy,
'pct_predicted' => $totalStudents > 0 ? round($totalPredicted / $totalStudents * 100) : 0,
'pct_actual' => $totalStudents > 0 ? round($totalActual / $totalStudents * 100) : 0,
'pct_confirmed_of_predicted' => $totalPredicted > 0 ? round($totalConfirmed / $totalPredicted * 100) : 0,
];
}
/**
* @param list<array<string, mixed>> $students
* @return array<string, int|float>
*/
private function genderBreakdown(array $students): array
{
$stats = ['boys' => 0, 'girls' => 0, 'other' => 0];
foreach ($students as $student) {
$stats[$this->genderKey($student['gender'] ?? '')]++;
}
$total = array_sum($stats);
return [
'total' => $total,
'boys' => $stats['boys'],
'girls' => $stats['girls'],
'other' => $stats['other'],
'pct_boys' => $total > 0 ? round(($stats['boys'] / $total) * 100, 1) : 0.0,
'pct_girls' => $total > 0 ? round(($stats['girls'] / $total) * 100, 1) : 0.0,
'pct_other' => $total > 0 ? round(($stats['other'] / $total) * 100, 1) : 0.0,
];
}
private function genderKey(?string $gender): string
{
$value = strtolower(trim((string) $gender));
return match (true) {
in_array($value, ['male', 'm', 'boy', 'boys'], true) => 'boys',
in_array($value, ['female', 'f', 'girl', 'girls'], true) => 'girls',
default => 'other',
};
}
/**
* @param list<float> $sortedScores
* @return array{threshold: float, winners: int, method: string}
*/
private function capByRank(array $sortedScores, int $max): array
{
$descending = array_reverse($sortedScores);
$threshold = $descending[$max - 1];
$winners = $this->countAtOrAbove($sortedScores, $threshold);
if ($winners <= $max) {
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'capped_25pct'];
}
$uniqueCandidates = array_values(array_unique(array_filter(
$sortedScores,
static fn (float $score): bool => $score > $threshold
)));
sort($uniqueCandidates);
foreach ($uniqueCandidates as $candidate) {
$winnerCount = $this->countAtOrAbove($sortedScores, $candidate);
if ($winnerCount <= $max) {
return ['threshold' => $candidate, 'winners' => $winnerCount, 'method' => 'capped_25pct'];
}
}
return ['threshold' => $sortedScores[0], 'winners' => count($sortedScores), 'method' => 'all_equal'];
}
/**
* @param list<float> $sortedScores
*/
private function empiricalPercentile(array $sortedScores, float $percentile): float
{
$count = count($sortedScores);
if ($count === 0) {
return 0.0;
}
$index = ($percentile / 100.0) * ($count - 1);
$lower = (int) floor($index);
$upper = (int) ceil($index);
if ($lower === $upper) {
return $sortedScores[$lower];
}
return $sortedScores[$lower] + ($index - $lower) * ($sortedScores[$upper] - $sortedScores[$lower]);
}
/**
* @param list<float> $scores
*/
private function countAtOrAbove(array $scores, float $threshold): int
{
return count(array_filter($scores, static fn (float $score): bool => $score >= $threshold));
}
}