add controllers, servoices

This commit is contained in:
root
2026-03-09 02:52:13 -04:00
parent c8de5f7edc
commit d76c871cb7
501 changed files with 34439 additions and 21843 deletions
@@ -0,0 +1,146 @@
<?php
namespace App\Services\Scores;
use App\Interfaces\ScoreCalculatorInterface;
use App\Models\AttendanceRecord;
use App\Models\CalendarEvent;
use App\Models\Configuration;
use RuntimeException;
class AttendanceCalculator implements ScoreCalculatorInterface
{
public function __construct(
private AttendanceRecord $attendanceModel,
private Configuration $configModel,
private CalendarEvent $calendarModel
) {
}
public function calculate(int $studentId, string $semester, string $schoolYear, ?int $classSectionId = null): array
{
$absences = AttendanceRecord::getTotalAbsences($studentId, $semester, $schoolYear);
$totalDays = null;
$normSemester = $this->normalizeSemester($semester);
if ($schoolYear !== '' && $normSemester !== '') {
$semRange = $this->getSemesterRange($schoolYear, $normSemester);
if ($semRange) {
try {
$events = CalendarEvent::getEventsBySchoolYearAndSemester($schoolYear, $normSemester);
$noSchoolSundays = 0;
foreach ($events as $event) {
if (!empty($event['no_school'])) {
$date = new \DateTime($event['date']);
if ($date->format('N') == 7) {
if ($date->format('Y-m-d') >= $semRange[0] && $date->format('Y-m-d') <= $semRange[1]) {
$noSchoolSundays++;
}
}
}
}
$totalDays = $this->countSundays($semRange[0], $semRange[1]) - $noSchoolSundays;
} catch (\Throwable $e) {
$totalDays = null;
}
}
}
if ($totalDays === null) {
$totalDays = $this->getConfiguredSemesterLength($semester);
} else {
$configuredDays = $this->getConfiguredSemesterLength($semester);
if ($configuredDays !== null) {
$totalDays = $configuredDays;
}
}
if ($totalDays === null || $totalDays < 0) {
throw new RuntimeException("Invalid attendance data: could not determine total days for student {$studentId}.");
}
if ($totalDays == 0) {
return ['attendance_score' => 100.0];
}
$attendanceRatio = ($totalDays + 1 - $absences) / $totalDays;
$score = min(100, max(0, $attendanceRatio * 100));
return ['attendance_score' => round($score, 2)];
}
protected function normalizeSemester(?string $input): string
{
$s = strtolower(trim((string) $input));
if ($s === 'fall' || str_contains($s, '1')) return 'fall';
if ($s === 'spring' || str_contains($s, '2')) return 'spring';
return '';
}
protected function getSemesterRange(string $schoolYear, string $semester): ?array
{
$norm = $this->normalizeSemester($semester);
if ($norm === '' || !preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $m)) {
if ($norm === '' || !preg_match('/^(\d{4})/', trim($schoolYear), $m)) {
return null;
}
$y1 = (int) $m[1];
$y2 = $y1 + 1;
} else {
$y1 = (int) $m[1];
$y2 = (int) $m[2];
}
$fallStartCfg = (string) (Configuration::getConfig('fall_semester_start') ?? '');
$fallEndCfg = (string) (Configuration::getConfig('fall_end_date') ?? '');
$springStartCfg = (string) (Configuration::getConfig('spring_semester_start') ?? '');
$springEndCfg = (string) (Configuration::getConfig('last_school_day') ?? '');
if ($norm === 'fall') {
$start = ($fallStartCfg !== '') ? sprintf('%04d-%s', $y1, date('m-d', strtotime($fallStartCfg))) : "{$y1}-09-01";
$end = ($fallEndCfg !== '') ? sprintf('%04d-%s', $y2, date('m-d', strtotime($fallEndCfg))) : "{$y2}-01-15";
return [$start, $end];
}
if ($norm === 'spring') {
$start = ($springStartCfg !== '') ? sprintf('%04d-%s', $y2, date('m-d', strtotime($springStartCfg))) : "{$y2}-01-16";
$end = ($springEndCfg !== '') ? sprintf('%04d-%s', $y2, date('m-d', strtotime($springEndCfg))) : "{$y2}-06-30";
return [$start, $end];
}
return null;
}
private function countSundays(string $startDate, string $endDate): int
{
$start = new \DateTime($startDate);
$end = new \DateTime($endDate);
$sundays = 0;
$interval = new \DateInterval('P1D');
$period = new \DatePeriod($start, $interval, $end->modify('+1 day'));
foreach ($period as $day) {
if ($day->format('N') == 7) {
$sundays++;
}
}
return $sundays;
}
private function getConfiguredSemesterLength(string $semester): ?int
{
$norm = $this->normalizeSemester($semester);
if ($norm === '') {
return null;
}
$configKey = $norm === 'fall' ? 'total_semester1_days' : 'total_semester2_days';
$value = Configuration::getConfig($configKey);
if (!is_numeric($value)) {
return null;
}
$length = (int) $value;
return $length > 0 ? $length : null;
}
}
+129
View File
@@ -0,0 +1,129 @@
<?php
namespace App\Services\Scores;
use App\Models\GradingLock;
use App\Models\MissingScoreOverride;
use App\Models\Student;
use Illuminate\Support\Facades\DB;
class ExamScoreService
{
public function __construct(private ScoreTermService $term)
{
}
public function list(string $table, int $classSectionId, ?string $semester, ?string $schoolYear): array
{
$semester = $this->term->semesterLabel($semester);
$schoolYear = $this->term->schoolYear($schoolYear);
$latestSub = DB::table($table)
->select('student_id', DB::raw('MAX(id) AS max_id'))
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->groupBy('student_id');
$rows = DB::table('students as s')
->select([
's.id as student_id',
's.school_id',
's.firstname',
's.lastname',
'ex.score',
])
->join('student_class as sc', function ($join) use ($classSectionId, $schoolYear) {
$join->on('sc.student_id', '=', 's.id')
->where('sc.class_section_id', '=', $classSectionId)
->where('sc.school_year', '=', $schoolYear);
})
->leftJoinSub($latestSub, 'latest', 'latest.student_id', '=', 's.id')
->leftJoin($table . ' as ex', 'ex.id', '=', 'latest.max_id')
->orderBy('s.lastname')
->orderBy('s.firstname')
->get()
->map(fn ($row) => (array) $row)
->all();
return [
'students' => $rows,
'semester' => $semester,
'schoolYear' => $schoolYear,
'scoresLocked' => GradingLock::isLocked($classSectionId, $semester, $schoolYear),
'missingOkMap' => MissingScoreOverride::getOverridesMap(
$classSectionId,
$semester,
$schoolYear,
$table === 'midterm_exam' ? 'midterm_exam' : 'final_exam'
),
];
}
public function update(string $table, int $classSectionId, string $semester, string $schoolYear, array $scores, array $missingOk, int $updatedBy): int
{
if (GradingLock::isLocked($classSectionId, $semester, $schoolYear)) {
throw new \RuntimeException('Scores are locked for this class.');
}
$studentIds = array_keys($scores);
$checkedItems = [];
foreach ($missingOk as $studentId => $flags) {
$checkedItems[] = [
'student_id' => (int) $studentId,
'item_index' => null,
];
}
MissingScoreOverride::replaceOverrides(
$classSectionId,
$semester,
$schoolYear,
$table === 'midterm_exam' ? 'midterm_exam' : 'final_exam',
$studentIds,
null,
$checkedItems,
$updatedBy,
true
);
$builder = DB::table($table);
$count = 0;
foreach ($scores as $studentId => $data) {
if (!is_numeric($studentId)) {
continue;
}
$rawScore = $data['score'] ?? null;
$normalizedScore = is_numeric($rawScore) ? (float) $rawScore : null;
$existing = $builder
->where('student_id', $studentId)
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
$payload = [
'student_id' => (int) $studentId,
'class_section_id' => $classSectionId,
'updated_by' => $updatedBy,
'score' => $normalizedScore,
'semester' => $semester,
'school_year' => $schoolYear,
'updated_at' => now(),
];
if ($existing) {
$builder->where('id', $existing->id)->update($payload);
} else {
$payload['created_at'] = now();
$builder->insert($payload);
}
$count++;
}
return $count;
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Services\Scores;
use App\Interfaces\ScoreCalculatorInterface;
use App\Models\Homework;
class HomeworkCalculator implements ScoreCalculatorInterface
{
public function __construct(private Homework $homeworkModel)
{
}
public function calculate(int $studentId, string $semester, string $schoolYear, ?int $classSectionId = null): array
{
$average = Homework::getAverageHomeworkScore($studentId, $semester, $schoolYear, $classSectionId);
return ['homework_avg' => $average !== null ? (float) $average : null];
}
}
@@ -0,0 +1,226 @@
<?php
namespace App\Services\Scores;
use App\Models\GradingLock;
use App\Models\Homework;
use App\Models\MissingScoreOverride;
use App\Models\Student;
use App\Models\StudentClass;
class HomeworkScoreService
{
public function __construct(private ScoreTermService $term)
{
}
public function list(int $classSectionId, ?string $semester, ?string $schoolYear): array
{
$semester = $this->term->semesterLabel($semester);
$schoolYear = $this->term->schoolYear($schoolYear);
$semVariants = $this->term->semesterVariants($semester);
$headerRows = Homework::query()
->select('homework_index')
->where('class_section_id', $classSectionId)
->whereIn('semester', $semVariants)
->where('school_year', $schoolYear)
->orderBy('homework_index')
->get();
$headers = $headerRows->pluck('homework_index')->map(fn ($v) => (int) $v)->unique()->values()->all();
$studentIds = StudentClass::query()
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->pluck('student_id')
->map(fn ($v) => (int) $v)
->all();
$students = Student::query()
->whereIn('id', $studentIds)
->where('is_active', 1)
->orderBy('lastname')
->orderBy('firstname')
->get()
->map(fn ($row) => [
'student_id' => (int) $row->id,
'firstname' => (string) $row->firstname,
'lastname' => (string) $row->lastname,
'scores' => [],
])
->all();
$scoresRows = Homework::query()
->select('student_id', 'homework_index', 'score')
->where('class_section_id', $classSectionId)
->whereIn('semester', $semVariants)
->where('school_year', $schoolYear)
->get()
->map(fn ($row) => (array) $row)
->all();
$scoresMap = [];
foreach ($scoresRows as $row) {
$scoresMap[(int) $row['student_id']][(int) $row['homework_index']] = $row['score'];
}
foreach ($students as &$student) {
foreach ($headers as $index) {
$student['scores'][(int) $index] = $scoresMap[$student['student_id']][$index] ?? '';
}
}
unset($student);
return [
'students' => $students,
'headers' => $headers,
'semester' => $semester,
'schoolYear' => $schoolYear,
'scoresLocked' => GradingLock::isLocked($classSectionId, $semester, $schoolYear),
'missingOkMap' => MissingScoreOverride::getOverridesMap($classSectionId, $semester, $schoolYear, 'homework'),
];
}
public function update(int $classSectionId, string $semester, string $schoolYear, array $scores, array $missingOk, int $updatedBy): int
{
if (GradingLock::isLocked($classSectionId, $semester, $schoolYear)) {
throw new \RuntimeException('Scores are locked for this class.');
}
$studentIds = array_keys($scores);
$indexSet = [];
foreach ($scores as $hwData) {
if (!is_array($hwData)) {
continue;
}
foreach ($hwData as $index => $_) {
if (is_numeric($index)) {
$indexSet[(int) $index] = true;
}
}
}
$checkedItems = [];
foreach ($missingOk as $studentId => $indexes) {
if (!is_array($indexes)) {
continue;
}
foreach ($indexes as $index => $flag) {
if (!is_numeric($index)) {
continue;
}
$checkedItems[] = [
'student_id' => (int) $studentId,
'item_index' => (int) $index,
];
}
}
MissingScoreOverride::replaceOverrides(
$classSectionId,
$semester,
$schoolYear,
'homework',
$studentIds,
array_keys($indexSet),
$checkedItems,
$updatedBy
);
$count = 0;
foreach ($scores as $studentId => $hwData) {
if (!is_numeric($studentId) || !is_array($hwData)) {
continue;
}
$student = Student::query()->find((int) $studentId);
if (!$student) {
continue;
}
foreach ($hwData as $index => $score) {
if (!is_numeric($index)) {
continue;
}
$rawScore = $score ?? null;
$isBlank = $rawScore === null || (is_string($rawScore) && trim($rawScore) === '');
$normalizedScore = (!$isBlank && is_numeric($rawScore)) ? (float) $rawScore : null;
$existing = Homework::query()
->where('student_id', $studentId)
->where('homework_index', $index)
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
$payload = [
'student_id' => (int) $studentId,
'school_id' => $student->school_id ?? null,
'class_section_id' => $classSectionId,
'updated_by' => $updatedBy,
'homework_index' => (int) $index,
'score' => $normalizedScore,
'semester' => $semester,
'school_year' => $schoolYear,
'updated_at' => now(),
];
if ($existing) {
$existing->update($payload);
} else {
$payload['created_at'] = now();
Homework::query()->create($payload);
}
$count++;
}
}
return $count;
}
public function addColumn(int $classSectionId, string $semester, string $schoolYear, int $updatedBy): int
{
$semVariants = $this->term->semesterVariants($semester);
$maxIndex = (int) Homework::query()
->where('class_section_id', $classSectionId)
->whereIn('semester', $semVariants)
->where('school_year', $schoolYear)
->max('homework_index');
$nextIndex = $maxIndex + 1;
$students = StudentClass::query()
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->get();
foreach ($students as $student) {
$exists = Homework::query()
->where('student_id', $student->student_id)
->where('homework_index', $nextIndex)
->where('class_section_id', $classSectionId)
->whereIn('semester', $semVariants)
->where('school_year', $schoolYear)
->first();
if ($exists) {
continue;
}
Homework::query()->create([
'student_id' => (int) $student->student_id,
'class_section_id' => $classSectionId,
'updated_by' => $updatedBy,
'homework_index' => $nextIndex,
'score' => null,
'semester' => $semester,
'school_year' => $schoolYear,
'created_at' => now(),
'updated_at' => now(),
]);
}
return $nextIndex;
}
}
@@ -0,0 +1,146 @@
<?php
namespace App\Services\Scores;
use App\Models\GradingLock;
use App\Models\MissingScoreOverride;
use App\Models\Participation;
use App\Models\Student;
use App\Models\StudentClass;
class ParticipationScoreService
{
public function __construct(private ScoreTermService $term)
{
}
public function list(int $classSectionId, ?string $semester, ?string $schoolYear): array
{
$semester = $this->term->semesterLabel($semester);
$schoolYear = $this->term->schoolYear($schoolYear);
$studentIds = StudentClass::query()
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->pluck('student_id')
->map(fn ($v) => (int) $v)
->all();
$students = Student::query()
->whereIn('id', $studentIds)
->where('is_active', 1)
->orderBy('lastname')
->orderBy('firstname')
->get()
->map(fn ($row) => [
'student_id' => (int) $row->id,
'school_id' => $row->school_id,
'firstname' => (string) $row->firstname,
'lastname' => (string) $row->lastname,
'scores' => [],
])
->all();
$scoresRows = Participation::query()
->select('student_id', 'score')
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->get();
$scoreMap = [];
foreach ($scoresRows as $row) {
$scoreMap[(int) $row->student_id] = $row->score;
}
foreach ($students as &$student) {
$student['scores'] = [
'score' => $scoreMap[$student['student_id']] ?? '',
];
}
unset($student);
return [
'students' => $students,
'semester' => $semester,
'schoolYear' => $schoolYear,
'scoresLocked' => GradingLock::isLocked($classSectionId, $semester, $schoolYear),
'missingOkMap' => MissingScoreOverride::getOverridesMap($classSectionId, $semester, $schoolYear, 'participation'),
];
}
public function update(int $classSectionId, string $semester, string $schoolYear, array $scores, array $missingOk, int $updatedBy): int
{
if (GradingLock::isLocked($classSectionId, $semester, $schoolYear)) {
throw new \RuntimeException('Scores are locked for this class.');
}
$studentIds = array_keys($scores);
$checkedItems = [];
foreach ($missingOk as $studentId => $flags) {
$checkedItems[] = [
'student_id' => (int) $studentId,
'item_index' => null,
];
}
MissingScoreOverride::replaceOverrides(
$classSectionId,
$semester,
$schoolYear,
'participation',
$studentIds,
null,
$checkedItems,
$updatedBy,
true
);
$count = 0;
foreach ($scores as $studentId => $data) {
if (!is_numeric($studentId)) {
continue;
}
if (!is_array($data)) {
$data = ['score' => $data];
}
$student = Student::query()->find((int) $studentId);
if (!$student) {
continue;
}
$rawScore = $data['score'] ?? null;
$isBlank = $rawScore === null || (is_string($rawScore) && trim($rawScore) === '');
$normalizedScore = (!$isBlank && is_numeric($rawScore)) ? (float) $rawScore : null;
$existing = Participation::query()
->where('student_id', $studentId)
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
$payload = [
'student_id' => (int) $studentId,
'school_id' => $student->school_id,
'class_section_id' => $classSectionId,
'updated_by' => $updatedBy,
'score' => $normalizedScore,
'semester' => $semester,
'school_year' => $schoolYear,
'updated_at' => now(),
];
if ($existing) {
$existing->update($payload);
} else {
$payload['created_at'] = now();
Participation::query()->create($payload);
}
$count++;
}
return $count;
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Services\Scores;
use App\Interfaces\ScoreCalculatorInterface;
use App\Models\Project;
class ProjectCalculator implements ScoreCalculatorInterface
{
public function __construct(private Project $projectModel)
{
}
public function calculate(int $studentId, string $semester, string $schoolYear, ?int $classSectionId = null): array
{
$average = Project::averageScore($studentId, $semester, $schoolYear, $classSectionId);
return ['project_avg' => $average !== null ? (float) $average : null];
}
}
+223
View File
@@ -0,0 +1,223 @@
<?php
namespace App\Services\Scores;
use App\Models\GradingLock;
use App\Models\MissingScoreOverride;
use App\Models\Project;
use App\Models\Student;
use App\Models\StudentClass;
use Illuminate\Support\Facades\DB;
class ProjectScoreService
{
public function __construct(private ScoreTermService $term)
{
}
public function list(int $classSectionId, ?string $semester, ?string $schoolYear): array
{
$semester = $this->term->semesterLabel($semester);
$schoolYear = $this->term->schoolYear($schoolYear);
$headerRows = Project::query()
->select('project_index')
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->orderBy('project_index')
->get();
$headers = $headerRows->pluck('project_index')->map(fn ($v) => (int) $v)->unique()->values()->all();
$studentIds = StudentClass::query()
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->pluck('student_id')
->map(fn ($v) => (int) $v)
->all();
$students = Student::query()
->whereIn('id', $studentIds)
->where('is_active', 1)
->orderBy('lastname')
->orderBy('firstname')
->get()
->map(fn ($row) => [
'student_id' => (int) $row->id,
'firstname' => (string) $row->firstname,
'lastname' => (string) $row->lastname,
'scores' => [],
])
->all();
$scoresRows = Project::query()
->select('student_id', 'project_index', 'score')
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->get()
->map(fn ($row) => (array) $row)
->all();
$scoresMap = [];
foreach ($scoresRows as $row) {
$scoresMap[(int) $row['student_id']][(int) $row['project_index']] = $row['score'];
}
foreach ($students as &$student) {
foreach ($headers as $index) {
$student['scores'][(int) $index] = $scoresMap[$student['student_id']][$index] ?? '';
}
}
unset($student);
return [
'students' => $students,
'headers' => $headers,
'semester' => $semester,
'schoolYear' => $schoolYear,
'scoresLocked' => GradingLock::isLocked($classSectionId, $semester, $schoolYear),
'missingOkMap' => MissingScoreOverride::getOverridesMap($classSectionId, $semester, $schoolYear, 'project'),
];
}
public function update(int $classSectionId, string $semester, string $schoolYear, array $scores, array $missingOk, int $updatedBy): int
{
if (GradingLock::isLocked($classSectionId, $semester, $schoolYear)) {
throw new \RuntimeException('Scores are locked for this class.');
}
$studentIds = array_keys($scores);
$indexSet = [];
foreach ($scores as $projects) {
if (!is_array($projects)) {
continue;
}
foreach ($projects as $index => $_) {
if (is_numeric($index)) {
$indexSet[(int) $index] = true;
}
}
}
$checkedItems = [];
foreach ($missingOk as $studentId => $indexes) {
if (!is_array($indexes)) {
continue;
}
foreach ($indexes as $index => $flag) {
if (!is_numeric($index)) {
continue;
}
$checkedItems[] = [
'student_id' => (int) $studentId,
'item_index' => (int) $index,
];
}
}
MissingScoreOverride::replaceOverrides(
$classSectionId,
$semester,
$schoolYear,
'project',
$studentIds,
array_keys($indexSet),
$checkedItems,
$updatedBy
);
$count = 0;
foreach ($scores as $studentId => $projects) {
if (!is_numeric($studentId) || !is_array($projects)) {
continue;
}
$student = Student::query()->find((int) $studentId);
if (!$student) {
continue;
}
foreach ($projects as $index => $score) {
if (!is_numeric($index)) {
continue;
}
$normalizedScore = is_numeric($score) ? (float) $score : null;
$existing = Project::query()
->where('student_id', $studentId)
->where('project_index', $index)
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
$payload = [
'student_id' => (int) $studentId,
'school_id' => $student->school_id,
'class_section_id' => $classSectionId,
'updated_by' => $updatedBy,
'project_index' => (int) $index,
'score' => $normalizedScore,
'semester' => $semester,
'school_year' => $schoolYear,
'updated_at' => now(),
];
if ($existing) {
$existing->update($payload);
} else {
$payload['created_at'] = now();
Project::query()->create($payload);
}
$count++;
}
}
return $count;
}
public function addColumn(int $classSectionId, string $semester, string $schoolYear, int $updatedBy): int
{
$maxIndex = (int) Project::query()
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->max('project_index');
$nextIndex = $maxIndex + 1;
$students = StudentClass::query()
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->get();
foreach ($students as $student) {
$exists = Project::query()
->where('student_id', $student->student_id)
->where('project_index', $nextIndex)
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
if ($exists) {
continue;
}
Project::query()->create([
'student_id' => (int) $student->student_id,
'class_section_id' => $classSectionId,
'updated_by' => $updatedBy,
'project_index' => $nextIndex,
'score' => null,
'semester' => $semester,
'school_year' => $schoolYear,
'created_at' => now(),
'updated_at' => now(),
]);
}
return $nextIndex;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace App\Services\Scores;
use App\Interfaces\ScoreCalculatorInterface;
use Illuminate\Support\Facades\DB;
class QuizCalculator implements ScoreCalculatorInterface
{
public function calculate(int $studentId, string $semester, string $schoolYear, ?int $classSectionId = null): array
{
$q = DB::table('quiz')
->select('score')
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear);
if ($classSectionId !== null) {
$q->where('class_section_id', $classSectionId);
}
$rows = $q->get();
$total = 0.0;
$count = 0;
foreach ($rows as $row) {
$score = $row->score ?? null;
if ($score === null || $score === '' || !is_numeric($score)) {
continue;
}
$total += (float) $score;
$count++;
}
$avg = $count > 0 ? round($total / $count, 2) : null;
return ['quiz_avg' => $avg !== null ? (float) $avg : null];
}
}
+220
View File
@@ -0,0 +1,220 @@
<?php
namespace App\Services\Scores;
use App\Models\GradingLock;
use App\Models\MissingScoreOverride;
use App\Models\Quiz;
use App\Models\Student;
use App\Models\StudentClass;
class QuizScoreService
{
public function __construct(private ScoreTermService $term)
{
}
public function list(int $classSectionId, ?string $semester, ?string $schoolYear): array
{
$semester = $this->term->semesterLabel($semester);
$schoolYear = $this->term->schoolYear($schoolYear);
$headerRows = Quiz::query()
->select('quiz_index')
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->orderBy('quiz_index')
->get();
$headers = $headerRows->pluck('quiz_index')->map(fn ($v) => (int) $v)->unique()->values()->all();
$studentIds = StudentClass::query()
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->pluck('student_id')
->map(fn ($v) => (int) $v)
->all();
$students = Student::query()
->whereIn('id', $studentIds)
->where('is_active', 1)
->orderBy('lastname')
->orderBy('firstname')
->get()
->map(fn ($row) => [
'student_id' => (int) $row->id,
'firstname' => (string) $row->firstname,
'lastname' => (string) $row->lastname,
'scores' => [],
])
->all();
$scoresRows = Quiz::query()
->select('student_id', 'quiz_index', 'score')
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->get()
->map(fn ($row) => (array) $row)
->all();
$scoresMap = [];
foreach ($scoresRows as $row) {
$scoresMap[(int) $row['student_id']][(int) $row['quiz_index']] = $row['score'];
}
foreach ($students as &$student) {
foreach ($headers as $index) {
$student['scores'][(int) $index] = $scoresMap[$student['student_id']][$index] ?? '';
}
}
unset($student);
return [
'students' => $students,
'headers' => $headers,
'semester' => $semester,
'schoolYear' => $schoolYear,
'scoresLocked' => GradingLock::isLocked($classSectionId, $semester, $schoolYear),
'missingOkMap' => MissingScoreOverride::getOverridesMap($classSectionId, $semester, $schoolYear, 'quiz'),
];
}
public function update(int $classSectionId, string $semester, string $schoolYear, array $scores, array $missingOk, int $updatedBy): int
{
if (GradingLock::isLocked($classSectionId, $semester, $schoolYear)) {
throw new \RuntimeException('Scores are locked for this class.');
}
$studentIds = array_keys($scores);
$indexSet = [];
foreach ($scores as $quizData) {
if (!is_array($quizData)) {
continue;
}
foreach ($quizData as $index => $_) {
if (is_numeric($index)) {
$indexSet[(int) $index] = true;
}
}
}
$checkedItems = [];
foreach ($missingOk as $studentId => $indexes) {
if (!is_array($indexes)) {
continue;
}
foreach ($indexes as $index => $flag) {
if (!is_numeric($index)) {
continue;
}
$checkedItems[] = [
'student_id' => (int) $studentId,
'item_index' => (int) $index,
];
}
}
MissingScoreOverride::replaceOverrides(
$classSectionId,
$semester,
$schoolYear,
'quiz',
$studentIds,
array_keys($indexSet),
$checkedItems,
$updatedBy
);
$count = 0;
foreach ($scores as $studentId => $quizData) {
if (!is_numeric($studentId)) {
continue;
}
if (!is_array($quizData)) {
$quizData = [1 => $quizData];
}
foreach ($quizData as $quizNumber => $score) {
if (!is_numeric($quizNumber)) {
continue;
}
$isBlank = $score === null || (is_string($score) && trim($score) === '');
$normalizedScore = (!$isBlank && is_numeric($score)) ? (float) $score : null;
$existing = Quiz::query()
->where('student_id', $studentId)
->where('quiz_index', $quizNumber)
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
$payload = [
'student_id' => (int) $studentId,
'class_section_id' => $classSectionId,
'updated_by' => $updatedBy,
'quiz_index' => (int) $quizNumber,
'score' => $normalizedScore,
'semester' => $semester,
'school_year' => $schoolYear,
'updated_at' => now(),
];
if ($existing) {
$existing->update($payload);
} else {
$payload['created_at'] = now();
Quiz::query()->create($payload);
}
$count++;
}
}
return $count;
}
public function addColumn(int $classSectionId, string $semester, string $schoolYear, int $updatedBy): int
{
$maxIndex = (int) Quiz::query()
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->max('quiz_index');
$nextIndex = $maxIndex + 1;
$students = StudentClass::query()
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->get();
foreach ($students as $student) {
$exists = Quiz::query()
->where('student_id', $student->student_id)
->where('quiz_index', $nextIndex)
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
if ($exists) {
continue;
}
Quiz::query()->create([
'student_id' => (int) $student->student_id,
'class_section_id' => $classSectionId,
'updated_by' => $updatedBy,
'quiz_index' => $nextIndex,
'score' => null,
'semester' => $semester,
'school_year' => $schoolYear,
'created_at' => now(),
'updated_at' => now(),
]);
}
return $nextIndex;
}
}
+340
View File
@@ -0,0 +1,340 @@
<?php
namespace App\Services\Scores;
use App\Models\Configuration;
use App\Models\GradingLock;
use App\Models\MissingScoreOverride;
use App\Models\ScoreComment;
use App\Models\SemesterScore;
use App\Models\Student;
use App\Models\StudentClass;
use Illuminate\Support\Facades\DB;
class ScoreCommentService
{
public function __construct(private ScoreTermService $term)
{
}
public function list(?string $classSectionId, ?string $semester, ?string $schoolYear): array
{
$schoolYear = $this->term->schoolYear($schoolYear);
$activeSemester = $this->term->semesterLabel($semester);
$isAllSections = is_string($classSectionId) && strtolower(trim($classSectionId)) === 'allsections';
$classSectionInt = $isAllSections ? null : (int) ($classSectionId ?? 0);
$builder = DB::table('student_class as sc')
->select('s.id as student_id', 's.firstname', 's.lastname', 's.school_id')
->join('students as s', 's.id', '=', 'sc.student_id')
->where('sc.school_year', $schoolYear)
->orderBy('s.lastname')
->orderBy('s.firstname')
->distinct();
if (!$isAllSections && $classSectionInt) {
$builder->where('sc.class_section_id', $classSectionInt);
}
$students = $builder->get()->map(fn ($row) => (array) $row)->all();
$comments = [];
if (!empty($students)) {
$studentIds = array_map(static fn ($r) => (int) $r['student_id'], $students);
$rawComments = ScoreComment::query()
->whereIn('student_id', $studentIds)
->whereIn('score_type', ['midterm', 'final', 'ptap', 'attendance'])
->where('semester', $activeSemester)
->where('school_year', $schoolYear)
->when(!$isAllSections && $classSectionInt, function ($q) use ($classSectionInt) {
$q->where(function ($w) use ($classSectionInt) {
$w->where('class_section_id', $classSectionInt)
->orWhereNull('class_section_id');
});
})
->get();
foreach ($rawComments as $comment) {
$sid = (int) $comment->student_id;
$typ = (string) $comment->score_type;
$comments[$sid][$typ] = [
'comment' => $comment->comment,
'comment_review' => $comment->comment_review,
'commented_by' => $comment->commented_by,
'reviewed_by' => $comment->reviewed_by,
];
}
$attendanceScores = [];
$scoreRows = SemesterScore::query()
->select(['student_id', 'attendance_score'])
->whereIn('student_id', $studentIds)
->where('semester', $activeSemester)
->where('school_year', $schoolYear)
->get();
foreach ($scoreRows as $row) {
$attendanceScores[(int) $row->student_id] = $row->attendance_score !== null
? (float) $row->attendance_score
: null;
}
foreach ($students as $student) {
$sid = (int) $student['student_id'];
$hasComment = isset($comments[$sid]['attendance']['comment'])
&& trim((string) $comments[$sid]['attendance']['comment']) !== '';
if ($hasComment) {
continue;
}
if (!function_exists('attendance_comment_from_score')) {
continue;
}
$score = $attendanceScores[$sid] ?? null;
if ($score === null) {
continue;
}
$auto = attendance_comment_from_score($score, $student['firstname'] ?? '');
if ($auto !== null) {
$comments[$sid]['attendance'] = [
'comment' => $auto,
'comment_review' => $comments[$sid]['attendance']['comment_review'] ?? null,
'commented_by' => $comments[$sid]['attendance']['commented_by'] ?? null,
'reviewed_by' => $comments[$sid]['attendance']['reviewed_by'] ?? null,
];
}
}
}
return [
'students' => $students,
'comments' => $comments,
'semester' => $activeSemester,
'schoolYear' => $schoolYear,
'classSectionId' => $isAllSections ? 'allsections' : $classSectionInt,
'scoresLocked' => (!$isAllSections && $classSectionInt)
? GradingLock::isLocked($classSectionInt, $activeSemester, $schoolYear)
: false,
];
}
public function save(int $classSectionId, string $semester, string $schoolYear, array $comments, array $missingOk, int $teacherId): array
{
if (GradingLock::isLocked($classSectionId, $semester, $schoolYear)) {
throw new \RuntimeException('Scores are locked for this class.');
}
$studentIds = array_map('intval', array_keys($comments));
$studentNames = $this->getStudentFirstNames($studentIds);
$isFall = strtolower($semester) === 'fall';
$isSpring = strtolower($semester) === 'spring';
$commentTypes = ['ptap_comment'];
if ($isFall) {
$commentTypes[] = 'midterm_comment';
}
if ($isSpring) {
$commentTypes[] = 'final_comment';
}
foreach ($commentTypes as $type) {
$checkedItems = [];
foreach ($missingOk as $studentId => $types) {
if (!is_array($types) || empty($types[$type])) {
continue;
}
$checkedItems[] = [
'student_id' => (int) $studentId,
'item_index' => null,
];
}
MissingScoreOverride::replaceOverrides(
$classSectionId,
$semester,
$schoolYear,
$type,
$studentIds,
null,
$checkedItems,
$teacherId,
true
);
}
$errors = [];
foreach ($comments as $studentId => $commentTypes) {
foreach ($commentTypes as $scoreType => $comment) {
$normalizedScoreType = strtolower((string) $scoreType);
$trimmedComment = trim((string) $comment);
if ($trimmedComment === '') {
continue;
}
$validationError = $this->validateComment($trimmedComment, (int) $studentId, $normalizedScoreType, $studentNames);
if ($validationError !== null) {
$errors[] = $validationError;
continue;
}
$existing = ScoreComment::query()
->where('student_id', $studentId)
->where('score_type', $normalizedScoreType)
->where('semester', $semester)
->where('school_year', $schoolYear)
->where(function ($q) use ($classSectionId) {
$q->where('class_section_id', $classSectionId)
->orWhereNull('class_section_id');
})
->first();
$data = [
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'score_type' => $normalizedScoreType,
'semester' => $semester,
'school_year' => $schoolYear,
'comment' => $trimmedComment,
'commented_by' => $teacherId,
'created_at' => now(),
];
if ($existing) {
$existing->update($data);
} else {
ScoreComment::query()->create($data);
}
}
}
return ['errors' => $errors];
}
public function update(int $classSectionId, string $semester, string $schoolYear, array $comments, array $reviews, int $teacherId): array
{
if (GradingLock::isLocked($classSectionId, $semester, $schoolYear)) {
throw new \RuntimeException('Scores are locked for this class.');
}
$studentIds = array_unique(array_map('intval', array_merge(array_keys($comments), array_keys($reviews))));
$studentNames = $this->getStudentFirstNames($studentIds);
$isReviewer = $this->isReviewer();
$errors = [];
foreach ($comments as $studentId => $types) {
foreach ($types as $scoreType => $commentText) {
$normalizedScoreType = strtolower((string) $scoreType);
$commentText = trim((string) $commentText);
$reviewText = isset($reviews[$studentId][$scoreType])
? trim((string) $reviews[$studentId][$scoreType])
: '';
if ($commentText === '' && $reviewText === '') {
continue;
}
if ($commentText !== '') {
$validationError = $this->validateComment($commentText, (int) $studentId, $normalizedScoreType, $studentNames);
if ($validationError !== null) {
$errors[] = $validationError;
continue;
}
}
$existing = ScoreComment::query()
->where('student_id', $studentId)
->where('score_type', $normalizedScoreType)
->where('semester', $semester)
->where('school_year', $schoolYear)
->where(function ($q) use ($classSectionId) {
$q->where('class_section_id', $classSectionId)
->orWhereNull('class_section_id');
})
->first();
$data = [
'comment' => $commentText === '' ? null : $commentText,
'commented_by' => $teacherId,
'class_section_id' => $classSectionId,
];
if ($isReviewer && $reviewText !== '') {
$data['comment_review'] = $reviewText;
$data['reviewed_by'] = $teacherId;
}
if ($existing) {
$existing->update($data);
} else {
ScoreComment::query()->create(array_merge([
'student_id' => $studentId,
'score_type' => $normalizedScoreType,
'semester' => $semester,
'school_year' => $schoolYear,
'class_section_id' => $classSectionId,
], $data));
}
}
}
return ['errors' => $errors];
}
private function getStudentFirstNames(array $studentIds): array
{
$ids = array_values(array_filter(array_unique(array_map('intval', $studentIds)), static fn ($id) => $id > 0));
if (empty($ids)) {
return [];
}
$rows = Student::query()
->select(['id', 'firstname'])
->whereIn('id', $ids)
->get();
$names = [];
foreach ($rows as $row) {
$names[(int) $row->id] = trim((string) $row->firstname);
}
return $names;
}
private function validateComment(string $comment, int $studentId, string $scoreType, array $studentNames): ?string
{
$trimmed = trim($comment);
if ($trimmed === '') {
return null;
}
$firstName = $studentNames[$studentId] ?? '';
if ($firstName === '') {
return "Missing student first name for student ID {$studentId}.";
}
$length = mb_strlen($trimmed, 'UTF-8');
if ($length < 100) {
return "{$firstName}'s {$scoreType} comment must be at least 100 characters.";
}
if ($length > 400) {
return "{$firstName}'s {$scoreType} comment must be at most 400 characters.";
}
$pattern = '/^' . preg_quote($firstName, '/') . '\\b/i';
if (!preg_match($pattern, $trimmed)) {
return "{$firstName}'s {$scoreType} comment must start with the student's first name.";
}
return null;
}
private function isReviewer(): bool
{
$currentUserRole = session()->get('role');
$reviewerRoles = (string) (Configuration::getConfig('comment_reviewer') ?? '');
if ($reviewerRoles === '') {
return false;
}
$allowedRoles = array_map('trim', explode(',', $reviewerRoles));
return in_array($currentUserRole, $allowedRoles, true);
}
}
@@ -0,0 +1,243 @@
<?php
namespace App\Services\Scores;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\GradingLock;
use App\Models\MissingScoreOverride;
use App\Models\ScoreComment;
use App\Models\SemesterScore;
use App\Models\Student;
use App\Models\StudentClass;
use App\Models\TeacherClass;
use Illuminate\Support\Facades\DB;
class ScoreDashboardService
{
public function __construct(private ScoreTermService $term)
{
}
public function overview(int $teacherId, ?int $classSectionId, ?string $semester, ?string $schoolYear): array
{
$schoolYear = $this->term->schoolYear($schoolYear);
$semesterLabel = $this->term->semesterLabel($semester, $semester);
$assignments = TeacherClass::query()
->where('teacher_id', $teacherId)
->when($schoolYear !== '', fn ($q) => $q->where('school_year', $schoolYear))
->get()
->map(fn ($row) => (array) $row)
->all();
if (empty($assignments)) {
return ['students' => [], 'assignments' => [], 'class_section_id' => null];
}
$allowedIds = array_values(array_unique(array_map(static fn ($a) => (int) ($a['class_section_id'] ?? 0), $assignments)));
$classSectionId = $classSectionId && in_array($classSectionId, $allowedIds, true) ? $classSectionId : $allowedIds[0];
$studentIds = StudentClass::query()
->where('class_section_id', $classSectionId)
->when($schoolYear !== '', fn ($q) => $q->where('school_year', $schoolYear))
->pluck('student_id')
->map(fn ($v) => (int) $v)
->all();
$students = Student::query()
->whereIn('id', $studentIds)
->where('is_active', 1)
->orderBy('lastname')
->orderBy('firstname')
->get()
->map(fn ($row) => [
'student_id' => (int) $row->id,
'firstname' => (string) $row->firstname,
'lastname' => (string) $row->lastname,
'school_id' => $row->school_id,
])
->all();
$scores = SemesterScore::query()
->where('class_section_id', $classSectionId)
->where('semester', $semesterLabel)
->where('school_year', $schoolYear)
->get()
->keyBy('student_id');
$comments = ScoreComment::query()
->where('semester', $semesterLabel)
->where('school_year', $schoolYear)
->whereIn('student_id', $studentIds)
->get();
$commentMap = [];
foreach ($comments as $comment) {
$commentMap[(int) $comment->student_id][(string) $comment->score_type] = [
'comment' => $comment->comment,
'comment_review' => $comment->comment_review,
];
}
foreach ($students as &$student) {
$sid = $student['student_id'];
$scoreRow = $scores[$sid] ?? null;
$student['scores'] = $scoreRow ? [
'homework' => $scoreRow->homework_avg,
'quiz' => $scoreRow->quiz_avg,
'project' => $scoreRow->project_avg,
'midterm_exam' => $scoreRow->midterm_exam_score,
'final_exam' => $scoreRow->final_exam_score,
'attendance' => $scoreRow->attendance_score,
'ptap' => $scoreRow->ptap_score,
'semester' => $scoreRow->semester_score,
] : [];
$student['comments'] = $commentMap[$sid] ?? [];
}
unset($student);
$classSection = ClassSection::query()->where('class_section_id', $classSectionId)->first();
return [
'students' => $students,
'class_section_id' => $classSectionId,
'class_section_name' => $classSection?->class_section_name,
'semester' => $semesterLabel,
'school_year' => $schoolYear,
'scoresLocked' => GradingLock::isLocked($classSectionId, $semesterLabel, $schoolYear),
];
}
public function submitScoresLock(int $classSectionId, string $semester, string $schoolYear, array $missingOk, int $teacherId): void
{
$isFall = strtolower($semester) === 'fall';
$isSpring = strtolower($semester) === 'spring';
$studentIds = array_keys((array) $missingOk);
$commentTypes = ['ptap_comment'];
if ($isFall) {
$commentTypes[] = 'midterm_comment';
}
if ($isSpring) {
$commentTypes[] = 'final_comment';
}
foreach ($commentTypes as $type) {
$checkedItems = [];
foreach ($missingOk as $studentId => $types) {
if (!is_array($types) || empty($types[$type])) {
continue;
}
$checkedItems[] = [
'student_id' => (int) $studentId,
'item_index' => null,
];
}
MissingScoreOverride::replaceOverrides(
$classSectionId,
$semester,
$schoolYear,
$type,
$studentIds,
null,
$checkedItems,
$teacherId,
true
);
}
$existing = GradingLock::getLock($classSectionId, $semester, $schoolYear);
if ($existing && $existing->is_locked) {
return;
}
if ($existing) {
$existing->update([
'is_locked' => 1,
'locked_by' => $teacherId > 0 ? $teacherId : null,
'locked_at' => now(),
]);
} else {
GradingLock::query()->create([
'class_section_id' => $classSectionId,
'semester' => $semester,
'school_year' => $schoolYear,
'is_locked' => 1,
'locked_by' => $teacherId > 0 ? $teacherId : null,
'locked_at' => now(),
]);
}
}
public function viewStudentScores(int $parentId, string $schoolYear): array
{
$students = DB::table('students')
->select('students.id', 'students.school_id', 'students.firstname', 'students.lastname', 'classSection.class_section_name')
->leftJoin('student_class', 'student_class.student_id', '=', 'students.id')
->leftJoin('classSection', 'classSection.class_section_id', '=', 'student_class.class_section_id')
->where('students.parent_id', $parentId)
->get()
->map(fn ($row) => (array) $row)
->all();
$scores = [];
if (!empty($students)) {
$studentIdList = array_column($students, 'id');
$semesterScores = DB::table('semester_scores')
->whereIn('student_id', $studentIdList)
->where('school_year', $schoolYear)
->get()
->map(fn ($row) => (array) $row)
->all();
$scoresByStudent = [];
foreach ($semesterScores as $score) {
$studentIdKey = (int) ($score['student_id'] ?? 0);
$semesterName = trim((string) ($score['semester'] ?? ''));
if ($studentIdKey === 0 || $semesterName === '') {
continue;
}
$normalizedSemester = ucfirst(strtolower($semesterName));
$scoresByStudent[$studentIdKey][$normalizedSemester] = $score;
}
foreach ($students as $student) {
$studentId = (int) $student['id'];
if (empty($scoresByStudent[$studentId])) {
continue;
}
foreach ($scoresByStudent[$studentId] as $semester => $score) {
$scores[$schoolYear][$semester][$studentId] = [
'student_id' => $studentId,
'school_id' => $student['school_id'],
'student_firstname' => $student['firstname'],
'student_lastname' => $student['lastname'],
'class_section_name' => $student['class_section_name'] ?? 'Unknown Class',
'comment' => $score['comment'] ?? null,
'scores' => [
'homework' => ['score' => $score['homework_avg'] ?? '-'],
'quiz' => ['score' => $score['quiz_avg'] ?? '-'],
'project' => ['score' => $score['project_avg'] ?? '-'],
'attendance' => ['score' => $score['attendance_score'] ?? '-'],
'participation_score' => ['score' => $score['participation_score'] ?? '-'],
'ptap' => ['score' => $score['ptap_score'] ?? '-'],
'midterm_exam' => ['score' => $score['midterm_exam_score'] ?? '-'],
'final_exam' => ['score' => $score['final_exam_score'] ?? '-'],
'semester' => ['score' => $score['semester_score'] ?? '-'],
],
];
}
}
}
$schoolYears = DB::table('semester_scores')->select('school_year')->distinct()->pluck('school_year')->all();
rsort($schoolYears);
return [
'scores' => $scores,
'schoolYears' => $schoolYears,
'selectedYear' => $schoolYear,
];
}
}
@@ -0,0 +1,105 @@
<?php
namespace App\Services\Scores;
use Illuminate\Support\Facades\DB;
class ScorePredictorDataService
{
public function classSections(string $schoolYear): array
{
return DB::table('classSection as cs')
->select('cs.*')
->join('student_class as sc', 'sc.class_section_id', '=', 'cs.class_section_id')
->where('sc.school_year', $schoolYear)
->notLike('cs.class_section_name', 'KG', 'after')
->groupBy('cs.id')
->orderBy('cs.class_section_name', 'ASC')
->get()
->map(fn ($row) => (array) $row)
->all();
}
public function students(string $schoolYear, ?int $classSectionId = null): array
{
$builder = DB::table('students as s')
->select([
's.id as student_id',
's.school_id',
's.firstname',
's.lastname',
'fall.semester_score as fall_score',
'spring.semester_score as spring_score',
'sc.class_section_id as class_section_id',
])
->leftJoin('student_class as sc', function ($join) use ($schoolYear) {
$join->on('sc.student_id', '=', 's.id')
->where('sc.school_year', '=', $schoolYear);
})
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
->leftJoin('semester_scores as fall', function ($join) use ($schoolYear) {
$join->on('fall.student_id', '=', 's.id')
->where('fall.semester', '=', 'fall')
->where('fall.school_year', '=', $schoolYear);
})
->leftJoin('semester_scores as spring', function ($join) use ($schoolYear) {
$join->on('spring.student_id', '=', 's.id')
->where('spring.semester', '=', 'spring')
->where('spring.school_year', '=', $schoolYear);
})
->where('s.is_active', 1)
->where(function ($q) {
$q->whereNull('cs.class_section_name')
->orWhere('cs.class_section_name', 'not like', 'KG%');
});
if (!empty($classSectionId)) {
$builder->where('sc.class_section_id', $classSectionId);
}
return $builder
->groupBy('s.id')
->get()
->map(fn ($row) => (array) $row)
->all();
}
public function springStats(string $schoolYear, ?int $classSectionId = null): array
{
$builder = DB::table('semester_scores')
->select('semester_scores.semester_score')
->join('student_class', 'student_class.student_id', '=', 'semester_scores.student_id')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'student_class.class_section_id')
->where('semester_scores.semester', 'spring')
->where('semester_scores.school_year', $schoolYear)
->where('student_class.school_year', $schoolYear)
->where(function ($q) {
$q->whereNull('cs.class_section_name')
->orWhere('cs.class_section_name', 'not like', 'KG%');
});
if (!empty($classSectionId)) {
$builder->where('student_class.class_section_id', $classSectionId);
}
$scores = $builder->pluck('semester_scores.semester_score')->filter(function ($v) {
return is_numeric($v);
})->map(fn ($v) => (float) $v)->all();
if (empty($scores)) {
return ['mean' => 70.0, 'std' => 0.0];
}
$mean = array_sum($scores) / count($scores);
$variance = 0.0;
foreach ($scores as $value) {
$variance += ($value - $mean) ** 2;
}
$variance = $variance / count($scores);
return [
'mean' => (float) $mean,
'std' => (float) sqrt($variance),
];
}
}
@@ -0,0 +1,143 @@
<?php
namespace App\Services\Scores;
class ScorePredictorRiskService
{
public function normalCdf(float $z): float
{
$t = 1 / (1 + 0.3275911 * abs($z));
$a1 = 0.254829592;
$a2 = -0.284496736;
$a3 = 1.421413741;
$a4 = -1.453152027;
$a5 = 1.061405429;
$erf = 1 - ((((($a5 * $t + $a4) * $t + $a3) * $t + $a2) * $t + $a1) * $t) * exp(-$z * $z);
$sign = $z < 0 ? -1 : 1;
return 0.5 * (1 + $sign * $erf);
}
public function evaluate(array $students, float $mean, float $std, float $trophyThreshold, float $targetLow): array
{
$results = [];
foreach ($students as $student) {
$fallScore = is_numeric($student['fall_score'] ?? null) ? (float) $student['fall_score'] : null;
$springScore = is_numeric($student['spring_score'] ?? null) ? (float) $student['spring_score'] : null;
$scoreParts = array_values(array_filter([$fallScore, $springScore], static fn ($v) => $v !== null));
$finalAvg = !empty($scoreParts) ? round(array_sum($scoreParts) / count($scoreParts), 1) : null;
$waitingForSpring = ($fallScore !== null && $springScore === null);
$requiredHigh = $waitingForSpring ? round(2 * $trophyThreshold - $fallScore, 2) : null;
$requiredLow = $waitingForSpring ? round(2 * $targetLow - $fallScore, 2) : null;
$zHigh = ($requiredHigh !== null && $std > 0) ? ($requiredHigh - $mean) / $std : null;
$zLow = ($requiredLow !== null && $std > 0) ? ($requiredLow - $mean) / $std : null;
$probHigh = $zHigh !== null ? 1 - $this->normalCdf($zHigh) : null;
$probLow = $zLow !== null ? $this->normalCdf($zLow) : null;
$riskHigh = match (true) {
$finalAvg !== null && !$waitingForSpring && $finalAvg >= $trophyThreshold => 'Achieved Trophy',
$finalAvg !== null && !$waitingForSpring => 'Unreachable',
$requiredHigh !== null && $requiredHigh > 100 => 'Unreachable',
is_null($probHigh) => 'New student',
$probHigh >= 0.66 => 'Low',
$probHigh >= 0.33 => 'Medium',
default => 'High',
};
$riskLow = match (true) {
$finalAvg !== null && !$waitingForSpring && $finalAvg < $targetLow => 'High',
$finalAvg !== null && !$waitingForSpring => 'Low',
$requiredLow !== null && $requiredLow > 100 => 'Unlikely to pass',
is_null($probLow) => 'New student',
$probLow >= 0.66 => 'High',
$probLow >= 0.33 => 'Medium',
default => 'Low',
};
if ($finalAvg === null) {
$status = 'Undetermined';
} elseif ($waitingForSpring) {
$status = in_array($riskLow, ['High', 'Unlikely to pass'], true) ? 'May fail' : 'Can pass';
} else {
$status = $finalAvg >= 60 ? 'Passed' : 'Failed';
}
$results[] = [
'student_id' => (int) ($student['student_id'] ?? 0),
'school_id' => $student['school_id'] ?? null,
'firstname' => $student['firstname'] ?? null,
'lastname' => $student['lastname'] ?? null,
'class_section_id' => $student['class_section_id'] ?? null,
'fall_score' => $student['fall_score'] ?? 'N/A',
'spring_score' => $student['spring_score'] ?? 'N/A',
'final_average' => $finalAvg ?? 'N/A',
'required_spring_score' => $requiredHigh ?? 'N/A',
'winning_risk' => $riskHigh,
'required_spring_to_pass' => $requiredLow ?? 'N/A',
'failure_risk' => $riskLow,
'status' => $status,
'trophy_awarded' => ($riskHigh === 'Achieved Trophy'),
'trophy_reason' => ($riskHigh === 'Achieved Trophy') ? 'threshold' : '',
];
}
return $this->applyTop3Trophy($results);
}
private function applyTop3Trophy(array $results): array
{
$bySection = [];
$hasThresholdTrophy = [];
foreach ($results as $idx => $row) {
$cid = (string) ($row['class_section_id'] ?? '');
$bySection[$cid][] = $idx;
if (($row['trophy_reason'] ?? '') === 'threshold') {
$hasThresholdTrophy[$cid] = true;
}
}
foreach ($bySection as $cid => $indices) {
if (!empty($hasThresholdTrophy[$cid])) {
continue;
}
$candidates = [];
foreach ($indices as $idx) {
$val = $results[$idx]['final_average'] ?? null;
if (!is_numeric($val)) {
$fall = $results[$idx]['fall_score'] ?? null;
$spring = $results[$idx]['spring_score'] ?? null;
$parts = [];
if (is_numeric($fall)) $parts[] = (float) $fall;
if (is_numeric($spring)) $parts[] = (float) $spring;
if (!empty($parts)) {
$val = array_sum($parts) / count($parts);
}
}
if (is_numeric($val)) {
$candidates[] = ['idx' => $idx, 'avg' => (float) $val];
}
}
if (empty($candidates)) {
continue;
}
usort($candidates, static fn ($a, $b) => $b['avg'] <=> $a['avg']);
$top = array_slice($candidates, 0, 3);
foreach ($top as $entry) {
$i = $entry['idx'];
$results[$i]['trophy_awarded'] = true;
$results[$i]['trophy_reason'] = 'top3';
$results[$i]['winning_risk'] = 'Top 3 Trophy';
}
}
usort($results, static fn ($a, $b) => ($b['final_average'] ?? 0) <=> ($a['final_average'] ?? 0));
return $results;
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Services\Scores;
use App\Models\Configuration;
class ScorePredictorService
{
public function __construct(
private ScorePredictorDataService $dataService,
private ScorePredictorRiskService $riskService
) {
}
public function report(?string $schoolYear, ?int $classSectionId = null): array
{
$currentSchoolYear = (string) (Configuration::getConfig('school_year') ?? '');
$semester = (string) (Configuration::getConfig('semester') ?? '');
$targetTrophy = (float) (Configuration::getConfig('trophy_score') ?? 94.0);
$targetLow = (float) (Configuration::getConfig('pass_score') ?? 60.0);
$selectedYear = $schoolYear ?: $currentSchoolYear;
$classSections = $this->dataService->classSections($selectedYear);
$students = $this->dataService->students($selectedYear, $classSectionId);
$stats = $this->dataService->springStats($selectedYear, $classSectionId);
$mean = (float) ($stats['mean'] ?? 70.0);
$std = (float) ($stats['std'] ?? 0.0);
$trophyThreshold = max(94.0, $targetTrophy);
$results = $this->riskService->evaluate($students, $mean, $std, $trophyThreshold, $targetLow);
return [
'students' => $results,
'school_year' => $selectedYear,
'class_sections' => $classSections,
'selected_class_section_id' => $classSectionId,
'semester' => $semester,
];
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Services\Scores;
use App\Models\Configuration;
class ScoreTermService
{
public function normalizeSemester(?string $input): string
{
$value = strtolower(trim((string) $input));
if ($value === 'fall' || str_contains($value, '1')) {
return 'fall';
}
if ($value === 'spring' || str_contains($value, '2')) {
return 'spring';
}
return '';
}
public function semesterLabel(?string $input, ?string $fallback = null): string
{
$normalized = $this->normalizeSemester($input);
if ($normalized !== '') {
return ucfirst($normalized);
}
if ($fallback && trim($fallback) !== '') {
return (string) $fallback;
}
return (string) (Configuration::getConfig('semester') ?? 'Fall');
}
public function schoolYear(?string $input, ?string $fallback = null): string
{
$value = trim((string) $input);
if ($value !== '') {
return $value;
}
if ($fallback && trim($fallback) !== '') {
return (string) $fallback;
}
return (string) (Configuration::getConfig('school_year') ?? '');
}
public function semesterVariants(?string $semester): array
{
$raw = trim((string) $semester);
$variants = [
$raw,
strtolower($raw),
ucfirst(strtolower($raw)),
];
$normalized = $this->normalizeSemester($raw);
if ($normalized === 'fall') {
$variants[] = 'First Semester';
$variants[] = 'Semester 1';
$variants[] = '1st Semester';
$variants[] = 'Fall Semester';
} elseif ($normalized === 'spring') {
$variants[] = 'Second Semester';
$variants[] = 'Semester 2';
$variants[] = '2nd Semester';
$variants[] = 'Spring Semester';
}
return array_values(array_unique(array_filter($variants, static fn ($v) => $v !== '')));
}
}
@@ -0,0 +1,227 @@
<?php
namespace App\Services\Scores;
use App\Interfaces\ScoreCalculatorInterface;
use App\Models\Configuration;
use App\Models\FinalExam;
use App\Models\MidtermExam;
use App\Models\Participation;
use App\Models\SemesterScore;
use App\Services\Scores\AttendanceCalculator;
use App\Services\Scores\HomeworkCalculator;
use App\Services\Scores\ProjectCalculator;
use App\Services\Scores\QuizCalculator;
use App\Models\AttendanceRecord;
use App\Models\CalendarEvent;
use App\Models\Homework;
use App\Models\Project;
use RuntimeException;
class SemesterScoreService
{
/** @var array<string, ScoreCalculatorInterface> */
private array $scoreCalculators = [];
private string $semester;
private string $schoolYear;
private ?int $actorId;
public function __construct(
?AttendanceCalculator $attendanceCalculator = null,
?HomeworkCalculator $homeworkCalculator = null,
?QuizCalculator $quizCalculator = null,
?ProjectCalculator $projectCalculator = null
) {
$this->semester = (string) (Configuration::getConfig('semester') ?? '');
$this->schoolYear = (string) (Configuration::getConfig('school_year') ?? '');
$this->actorId = (int) (auth()->id() ?? 0) ?: null;
$attendanceCalculator = $attendanceCalculator ?? new AttendanceCalculator(
new AttendanceRecord(),
new Configuration(),
new CalendarEvent()
);
$homeworkCalculator = $homeworkCalculator ?? new HomeworkCalculator(new Homework());
$quizCalculator = $quizCalculator ?? new QuizCalculator();
$projectCalculator = $projectCalculator ?? new ProjectCalculator(new Project());
$this->scoreCalculators = [
AttendanceCalculator::class => $attendanceCalculator,
HomeworkCalculator::class => $homeworkCalculator,
QuizCalculator::class => $quizCalculator,
ProjectCalculator::class => $projectCalculator,
];
}
public function updateScoresForStudents(array $students, ?string $semester = null, ?string $schoolYear = null): void
{
$semester = $semester ?? $this->semester;
$schoolYear = $schoolYear ?? $this->schoolYear;
foreach ($students as $student) {
$this->updateStudentScores($student, $semester, $schoolYear);
}
}
public function updateStudentScores(array $student, string $semester, string $schoolYear): void
{
foreach (['student_id', 'school_id', 'class_section_id'] as $field) {
if (!isset($student[$field])) {
throw new RuntimeException("Missing required student field: {$field}");
}
}
$updatedBy = isset($student['updated_by'])
? (int) $student['updated_by']
: ($this->actorId ?? null);
$scoreData = $this->calculateAllScores(
(int) $student['student_id'],
$semester,
$schoolYear,
(int) ($student['class_section_id'] ?? 0) ?: null
);
$this->saveStudentScores(
(int) $student['student_id'],
(string) $student['school_id'],
(int) $student['class_section_id'],
$updatedBy,
$semester,
$schoolYear,
$scoreData
);
}
private function calculateAllScores(int $studentId, string $semester, string $schoolYear, ?int $classSectionId = null): array
{
$scoreData = [
'updated_at' => now(),
];
foreach ($this->scoreCalculators as $calculator) {
try {
$scoreData = array_merge(
$scoreData,
$calculator->calculate($studentId, $semester, $schoolYear, $classSectionId)
);
} catch (\Throwable $e) {
// ignore calculator errors to keep scoring pipeline running
}
}
if (!isset($scoreData['participation_score'])) {
$participationScore = Participation::getParticipationScore($studentId, $semester, $schoolYear, $classSectionId);
$scoreData['participation_score'] = $participationScore !== null ? (float) $participationScore : null;
}
$scoreData['ptap_score'] = $this->calculatePTAPScore($scoreData);
$midfinal = $this->calculateSemesterScore($scoreData, $semester, $studentId, $schoolYear, $classSectionId);
return array_merge($scoreData, [
'semester_score' => $midfinal['semester_score'],
'midterm_exam_score' => $midfinal['midterm_score'],
'final_exam_score' => $midfinal['final_score'],
'participation_score' => $scoreData['participation_score'],
]);
}
private function calculatePTAPScore(array $scores): ?float
{
$avgHomework = $scores['homework_avg'] ?? null;
$avgQuiz = $scores['quiz_avg'] ?? null;
$avgProject = $scores['project_avg'] ?? null;
$avgParticipation = $scores['participation_score'] ?? null;
$hasTests = $avgQuiz !== null;
$hasProjects = $avgProject !== null;
if (!$hasTests && !$hasProjects) {
if ($avgHomework === null || $avgParticipation === null) {
return null;
}
return round(($avgHomework * 0.5) + ($avgParticipation * 0.5), 1);
}
if (!$hasProjects) {
if ($avgHomework === null || $avgParticipation === null || $avgQuiz === null) {
return null;
}
return round(($avgHomework * 0.4) + ($avgParticipation * 0.4) + ($avgQuiz * 0.2), 1);
}
if (!$hasTests) {
if ($avgHomework === null || $avgParticipation === null || $avgProject === null) {
return null;
}
return round(($avgHomework * 0.34) + ($avgParticipation * 0.33) + ($avgProject * 0.33), 1);
}
if ($avgHomework === null || $avgParticipation === null || $avgProject === null || $avgQuiz === null) {
return null;
}
return round(
($avgHomework * 0.3) + ($avgParticipation * 0.3) + ($avgProject * 0.3) + ($avgQuiz * 0.1),
1
);
}
private function calculateSemesterScore(array $scores, string $semester, int $studentId, string $schoolYear, ?int $classSectionId): array
{
$ptap = $scores['ptap_score'] ?? null;
$attendance = $scores['attendance_score'] ?? null;
$out = [
'midterm_score' => null,
'final_score' => null,
'semester_score' => null,
];
if (strcasecmp($semester, 'Fall') === 0) {
$mid = $scores['midterm_exam_score'] ?? MidtermExam::getMidtermExamScore($studentId, $semester, $schoolYear, $classSectionId);
$out['midterm_score'] = $mid !== null ? round((float) $mid, 1) : null;
if ($ptap !== null && $attendance !== null && $out['midterm_score'] !== null) {
$out['semester_score'] = round(($attendance * 0.2) + ($ptap * 0.2) + ($out['midterm_score'] * 0.6), 1);
}
return $out;
}
if (strcasecmp($semester, 'Spring') === 0) {
$fin = $scores['final_exam_score'] ?? FinalExam::getFinalExamScore($studentId, $semester, $schoolYear, $classSectionId);
$out['final_score'] = $fin !== null ? round((float) $fin, 1) : null;
if ($ptap !== null && $attendance !== null && $out['final_score'] !== null) {
$out['semester_score'] = round(($attendance * 0.2) + ($ptap * 0.2) + ($out['final_score'] * 0.6), 1);
}
return $out;
}
return $out;
}
private function saveStudentScores(
int $studentId,
string $schoolId,
int $classSectionId,
?int $updatedBy,
string $semester,
string $schoolYear,
array $scoreData
): void {
$payload = array_merge([
'student_id' => $studentId,
'school_id' => $schoolId,
'class_section_id' => $classSectionId,
'semester' => $semester,
'school_year' => $schoolYear,
'updated_at' => now(),
], $scoreData);
if ($updatedBy !== null) {
$payload['updated_by'] = $updatedBy;
}
SemesterScore::upsertScore($payload);
}
}