add controllers, servoices
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user