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,403 @@
<?php
namespace App\Services\Grading;
use App\Models\CurrentFlag;
use App\Models\ParentMeetingSchedule;
use App\Models\ScoreComment;
use App\Models\Student;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use RuntimeException;
class GradingBelowSixtyService
{
public function listRows(string $schoolYear, string $semester): array
{
$semesterKey = strtolower(trim($semester));
$rows = DB::table('semester_scores as ss')
->select([
's.id as student_id',
's.school_id',
's.firstname',
's.lastname',
'cs.class_section_name',
'ss.homework_avg',
'ss.project_avg',
'ss.participation_score',
DB::raw('COALESCE(ss.test_avg, ss.quiz_avg) as test_avg'),
'ss.ptap_score',
'ss.attendance_score',
'ss.midterm_exam_score',
'ss.final_exam_score',
'ss.semester_score',
])
->join('students as s', 's.id', '=', 'ss.student_id')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ss.class_section_id')
->where('s.is_active', 1)
->where('ss.school_year', $schoolYear)
->whereRaw('LOWER(TRIM(ss.semester)) = ?', [$semesterKey])
->whereNotNull('ss.semester_score')
->where('ss.semester_score', '<', 60)
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->get()
->map(fn ($row) => (array) $row)
->all();
if (empty($rows)) {
return [];
}
$studentIds = array_values(array_unique(array_map(
static fn ($r) => (int) ($r['student_id'] ?? 0),
$rows
)));
$studentIds = array_values(array_filter($studentIds, static fn ($id) => $id > 0));
$commentMap = [];
if (!empty($studentIds)) {
$commentRows = ScoreComment::query()
->select('student_id', 'comment', 'created_at')
->where('score_type', 'general')
->where('school_year', $schoolYear)
->whereRaw('LOWER(TRIM(semester)) = ?', [$semesterKey])
->whereIn('student_id', $studentIds)
->orderBy('created_at', 'DESC')
->get();
foreach ($commentRows as $row) {
$sid = (int) $row->student_id;
if ($sid > 0 && !isset($commentMap[$sid])) {
$commentMap[$sid] = (string) ($row->comment ?? '');
}
}
}
$statusMap = [];
if (!empty($studentIds)) {
$flagRows = CurrentFlag::query()
->select('student_id', 'flag_state')
->where('flag', 'grade')
->where('school_year', $schoolYear)
->whereRaw('LOWER(TRIM(semester)) = ?', [$semesterKey])
->whereIn('student_id', $studentIds)
->get();
foreach ($flagRows as $row) {
$sid = (int) $row->student_id;
if ($sid > 0) {
$statusMap[$sid] = (string) ($row->flag_state ?? '');
}
}
}
foreach ($rows as &$row) {
$sid = (int) ($row['student_id'] ?? 0);
$row['comment'] = $commentMap[$sid] ?? '';
$flagState = strtolower(trim((string) ($statusMap[$sid] ?? '')));
$row['status'] = ($flagState === 'closed' || $flagState === 'canceled') ? 'Closed' : 'Open';
}
unset($row);
return $rows;
}
public function emailContext(int $studentId, string $schoolYear, string $semester): array
{
$row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
if (empty($row)) {
throw new RuntimeException('Student record not found for the selected term.');
}
$studentName = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? ''));
$subject = $this->buildBelowSixtySubject($studentName, $semester, $schoolYear);
$parentName = $this->fetchBelowSixtyParentName($studentId);
$scores = [
'homework_avg' => $row['homework_avg'] ?? null,
'project_avg' => $row['project_avg'] ?? null,
'participation_score' => $row['participation_score'] ?? null,
'test_avg' => $row['test_avg'] ?? null,
'ptap_score' => $row['ptap_score'] ?? null,
'attendance_score' => $row['attendance_score'] ?? null,
'midterm_exam_score' => $row['midterm_exam_score'] ?? null,
'semester_score' => $row['semester_score'] ?? null,
];
return [
'student_id' => $studentId,
'student_name' => $studentName !== '' ? $studentName : 'Student',
'parent_name' => $parentName,
'class_section_name' => $row['class_section_name'] ?? '',
'semester' => $semester,
'school_year' => $schoolYear,
'scores' => $scores,
'comment' => $row['comment'] ?? '',
'subject' => $subject,
];
}
public function sendEmail(array $payload): void
{
Event::dispatch('below60.email', $payload);
}
public function updateStatus(int $studentId, string $semester, string $schoolYear, string $status, string $note, ?int $userId): void
{
$status = ucfirst(strtolower($status));
if (!in_array($status, ['Open', 'Closed'], true)) {
throw new RuntimeException('Invalid status.');
}
$semesterKey = strtolower(trim($semester));
$existing = CurrentFlag::query()
->where('student_id', $studentId)
->where('flag', 'grade')
->where('school_year', $schoolYear)
->whereRaw('LOWER(TRIM(semester)) = ?', [$semesterKey])
->first();
$now = now();
if ($existing) {
$data = [
'flag_state' => $status,
'flag_datetime' => $now,
'updated_at' => $now,
];
if ($status === 'Open') {
$data['updated_by_open'] = $userId;
if ($note !== '') {
$prev = (string) ($existing->open_description ?? '');
$data['open_description'] = trim($prev . PHP_EOL . $note);
}
} else {
$data['updated_by_closed'] = $userId;
if ($note !== '') {
$prev = (string) ($existing->close_description ?? '');
$data['close_description'] = trim($prev . PHP_EOL . $note);
}
}
$existing->update($data);
return;
}
$row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
$studentName = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? ''));
$grade = (string) ($row['class_section_name'] ?? '');
$data = [
'student_id' => $studentId,
'student_name' => $studentName !== '' ? $studentName : 'Student',
'grade' => $grade,
'flag' => 'grade',
'flag_datetime' => $now,
'flag_state' => $status,
'semester' => $semester,
'school_year' => $schoolYear,
'updated_at' => $now,
];
if ($status === 'Open') {
$data['updated_by_open'] = $userId;
if ($note !== '') {
$data['open_description'] = $note;
}
} else {
$data['updated_by_closed'] = $userId;
if ($note !== '') {
$data['close_description'] = $note;
}
}
CurrentFlag::query()->create($data);
}
public function meetingContext(int $studentId, string $schoolYear, string $semester): array
{
$context = $this->fetchBelowSixtyMeetingContext($studentId, $schoolYear, $semester);
if (empty($context)) {
throw new RuntimeException('Student record not found for the selected term.');
}
return $context + [
'student_id' => $studentId,
'semester' => $semester,
'school_year' => $schoolYear,
];
}
public function saveMeeting(array $payload, ?int $userId): void
{
$studentId = (int) ($payload['student_id'] ?? 0);
$semester = (string) ($payload['semester'] ?? '');
$schoolYear = (string) ($payload['school_year'] ?? '');
$date = trim((string) ($payload['date'] ?? ''));
$time = trim((string) ($payload['time'] ?? ''));
$notes = trim((string) ($payload['notes'] ?? ''));
if ($studentId <= 0 || $semester === '' || $schoolYear === '' || $date === '') {
throw new RuntimeException('Missing required fields.');
}
$context = $this->fetchBelowSixtyMeetingContext($studentId, $schoolYear, $semester);
if (empty($context)) {
throw new RuntimeException('Student record not found for the selected term.');
}
ParentMeetingSchedule::query()->create([
'student_id' => $studentId,
'parent_user_id' => $context['parent_user_id'] ?? null,
'parent_name' => $context['parent_name'],
'student_name' => $context['student_name'],
'class_section_name' => $context['class_section_name'],
'date' => $date,
'time' => $time !== '' ? $time : null,
'notes' => $notes !== '' ? $notes : null,
'semester' => $semester,
'school_year' => $schoolYear,
'status' => 'scheduled',
'created_by' => $userId,
]);
}
private function fetchBelowSixtyEmailRow(int $studentId, string $schoolYear, string $semester): array
{
$semesterKey = strtolower(trim($semester));
$row = DB::table('semester_scores as ss')
->select([
's.id as student_id',
's.firstname',
's.lastname',
'cs.class_section_name',
'ss.homework_avg',
'ss.project_avg',
'ss.participation_score',
DB::raw('COALESCE(ss.test_avg, ss.quiz_avg) as test_avg'),
'ss.ptap_score',
'ss.attendance_score',
'ss.midterm_exam_score',
'ss.semester_score',
])
->join('students as s', 's.id', '=', 'ss.student_id')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ss.class_section_id')
->where('ss.school_year', $schoolYear)
->where('ss.student_id', $studentId)
->where('s.is_active', 1)
->whereRaw('LOWER(TRIM(ss.semester)) = ?', [$semesterKey])
->first();
if (!$row) {
return [];
}
$commentRow = ScoreComment::query()
->select('comment')
->where('score_type', 'general')
->where('school_year', $schoolYear)
->whereRaw('LOWER(TRIM(semester)) = ?', [$semesterKey])
->where('student_id', $studentId)
->orderBy('created_at', 'DESC')
->first();
$row = (array) $row;
$row['comment'] = (string) ($commentRow?->comment ?? '');
return $row;
}
private function fetchBelowSixtyParentName(int $studentId): string
{
$parentName = 'Parent/Guardian';
try {
$rows = DB::select(
"SELECT u.firstname, u.lastname
FROM family_students fs
JOIN family_guardians fg ON fg.family_id = fs.family_id
JOIN users u ON u.id = fg.user_id
WHERE fs.student_id = ?
ORDER BY fg.is_primary DESC, u.lastname, u.firstname
LIMIT 1",
[$studentId]
);
if (!empty($rows[0])) {
$candidate = trim((string) ($rows[0]->firstname ?? '') . ' ' . (string) ($rows[0]->lastname ?? ''));
if ($candidate !== '') {
$parentName = $candidate;
}
}
} catch (\Throwable $e) {
}
return $parentName;
}
private function buildBelowSixtySubject(string $studentName, string $semester, string $schoolYear): string
{
$subject = 'Student Performance Alert';
if ($studentName !== '') {
$subject .= ' — ' . $studentName;
}
if ($semester !== '' || $schoolYear !== '') {
$subject .= ' (' . trim($semester . ' ' . $schoolYear) . ')';
}
return $subject;
}
private function fetchBelowSixtyMeetingContext(int $studentId, string $schoolYear, string $semester): array
{
$row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
if (empty($row)) {
return [];
}
$parentName = 'Parent/Guardian';
$parentUserId = null;
try {
$pRows = DB::select(
"SELECT u.id AS user_id, u.firstname, u.lastname
FROM family_students fs
JOIN family_guardians fg ON fg.family_id = fs.family_id
JOIN users u ON u.id = fg.user_id
WHERE fs.student_id = ?
ORDER BY fg.is_primary DESC, u.lastname, u.firstname
LIMIT 1",
[$studentId]
);
if (!empty($pRows[0])) {
$parentUserId = (int) ($pRows[0]->user_id ?? 0) ?: null;
$parentName = trim((string) ($pRows[0]->firstname ?? '') . ' ' . (string) ($pRows[0]->lastname ?? ''));
if ($parentName === '') {
$parentName = 'Parent/Guardian';
}
}
} catch (\Throwable $e) {
}
if ($parentUserId === null) {
try {
$srow = DB::selectOne(
"SELECT s.parent_id, u.firstname, u.lastname
FROM students s
LEFT JOIN users u ON u.id = s.parent_id
WHERE s.id = ?
LIMIT 1",
[$studentId]
);
if (!empty($srow)) {
$parentUserId = (int) ($srow->parent_id ?? 0) ?: null;
$fallbackName = trim((string) ($srow->firstname ?? '') . ' ' . (string) ($srow->lastname ?? ''));
if ($fallbackName !== '') {
$parentName = $fallbackName;
}
}
} catch (\Throwable $e) {
}
}
$studentName = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? ''));
return [
'student_name' => $studentName !== '' ? $studentName : 'Student',
'parent_name' => $parentName,
'parent_user_id' => $parentUserId,
'class_section_name' => (string) ($row['class_section_name'] ?? ''),
];
}
}
+118
View File
@@ -0,0 +1,118 @@
<?php
namespace App\Services\Grading;
use App\Models\GradingLock;
use App\Models\ClassSection;
use RuntimeException;
class GradingLockService
{
public function toggle(int $classSectionId, string $semester, string $schoolYear, ?int $userId): bool
{
if ($classSectionId <= 0 || $semester === '' || $schoolYear === '') {
throw new RuntimeException('Missing class section or term.');
}
$existing = GradingLock::getLock($classSectionId, $semester, $schoolYear);
if ($existing && $existing->is_locked) {
$existing->update([
'is_locked' => 0,
'locked_by' => null,
'locked_at' => null,
]);
return false;
}
if ($existing) {
$existing->update([
'is_locked' => 1,
'locked_by' => $userId ?: null,
'locked_at' => now(),
]);
} else {
GradingLock::query()->create([
'class_section_id' => $classSectionId,
'semester' => $semester,
'school_year' => $schoolYear,
'is_locked' => 1,
'locked_by' => $userId ?: null,
'locked_at' => now(),
]);
}
return true;
}
public function lockAll(string $semester, string $schoolYear, ?int $userId): int
{
if ($semester === '' || $schoolYear === '') {
throw new RuntimeException('Missing semester or school year.');
}
$sectionIds = ClassSection::query()
->select('class_section_id')
->groupBy('class_section_id')
->pluck('class_section_id')
->map(fn ($v) => (int) $v)
->filter(fn ($v) => $v > 0)
->values()
->all();
if (empty($sectionIds)) {
return 0;
}
$existingLocks = GradingLock::query()
->whereIn('class_section_id', $sectionIds)
->where('semester', $semester)
->where('school_year', $schoolYear)
->get();
$existingBySection = [];
foreach ($existingLocks as $row) {
$sid = (int) $row->class_section_id;
if ($sid > 0) {
$existingBySection[$sid] = $row;
}
}
$now = now();
$insertRows = [];
$count = 0;
foreach ($sectionIds as $sid) {
if (!empty($existingBySection[$sid])) {
if ($existingBySection[$sid]->is_locked) {
continue;
}
$existingBySection[$sid]->update([
'is_locked' => 1,
'locked_by' => $userId ?: null,
'locked_at' => $now,
]);
$count++;
continue;
}
$insertRows[] = [
'class_section_id' => $sid,
'semester' => $semester,
'school_year' => $schoolYear,
'is_locked' => 1,
'locked_by' => $userId ?: null,
'locked_at' => $now,
'created_at' => $now,
'updated_at' => $now,
];
$count++;
}
if (!empty($insertRows)) {
GradingLock::query()->insert($insertRows);
}
return $count;
}
}
@@ -0,0 +1,439 @@
<?php
namespace App\Services\Grading;
use App\Models\AttendanceRecord;
use App\Models\CalendarEvent;
use App\Models\Configuration;
use App\Models\ClassSection;
use App\Models\GradingLock;
use App\Models\Student;
use App\Services\Scores\AttendanceCalculator;
use App\Services\Scores\SemesterScoreService;
use Illuminate\Support\Facades\DB;
class GradingOverviewService
{
private AttendanceCalculator $attendanceCalculator;
public function __construct(private SemesterScoreService $semesterScoreService)
{
$this->attendanceCalculator = new AttendanceCalculator(
new AttendanceRecord(),
new Configuration(),
new CalendarEvent()
);
}
public function overview(?int $classId, ?string $semester, ?string $schoolYear): array
{
$schoolYear = $schoolYear ?: (string) (Configuration::getConfig('school_year') ?? '');
$configuredSemester = (string) (Configuration::getConfig('semester') ?? '');
$semesterOptions = $this->getSemestersForSchoolYear($schoolYear, $configuredSemester);
$semester = $this->resolveSemesterSelection($semester, $semesterOptions, $configuredSemester);
$this->ensureParentReleaseKeyExists('Fall');
$this->ensureParentReleaseKeyExists('Spring');
$scoresReleased = $this->getParentScoresReleasedForSemester($semester);
$scoresReleasedFall = $this->getParentScoresReleasedForSemester('Fall');
$scoresReleasedSpring = $this->getParentScoresReleasedForSemester('Spring');
if ($classId && $this->semesterScoreService) {
$sectionIds = ClassSection::query()
->select('class_section_id')
->where('class_id', $classId)
->pluck('class_section_id')
->map(fn ($v) => (int) $v)
->all();
foreach ($sectionIds as $sectionId) {
$studentTeacherInfo = Student::getStudentInfoByClassSectionId(
$sectionId,
$semester,
$schoolYear
);
if (empty($studentTeacherInfo)) {
continue;
}
try {
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
} catch (\Throwable $e) {
}
}
}
$rows = $this->buildGradingRows($semester, $schoolYear);
$counts = $this->loadScoreCounts($rows, $semester, $schoolYear);
$sectionsNeedingRefresh = [];
foreach ($rows as $row) {
$sectionId = (int) ($row['section_id'] ?? 0);
if ($sectionId <= 0) {
continue;
}
if ($row['ss_ptap_score'] === null || $row['ss_semester_score'] === null) {
$sectionsNeedingRefresh[$sectionId] = true;
}
}
if (!empty($sectionsNeedingRefresh)) {
foreach (array_keys($sectionsNeedingRefresh) as $sectionId) {
$studentTeacherInfo = Student::getStudentInfoByClassSectionId(
$sectionId,
$semester,
$schoolYear
);
if (empty($studentTeacherInfo)) {
continue;
}
try {
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
} catch (\Throwable $e) {
}
}
$rows = $this->buildGradingRows($semester, $schoolYear);
}
$grades = [];
$studentsBySection = [];
foreach ($rows as $row) {
$sectionId = (int) ($row['section_id'] ?? 0);
$classIdValue = (int) ($row['class_id'] ?? 0);
$sectionName = (string) ($row['class_section_name'] ?? '');
if ($sectionId <= 0 || $classIdValue <= 0) {
continue;
}
if (!isset($grades[$classIdValue])) {
$grades[$classIdValue] = [];
}
$exists = false;
foreach ($grades[$classIdValue] as $section) {
if ((int) $section['class_section_id'] === $sectionId) {
$exists = true;
break;
}
}
if (!$exists) {
$grades[$classIdValue][] = [
'class_section_id' => $sectionId,
'class_section_name' => $sectionName,
];
}
$studentId = (int) ($row['student_id'] ?? 0);
if ($studentId <= 0) {
continue;
}
$attendanceScore = $this->calculateAttendanceScoreForStudent(
$studentId,
$semester,
$schoolYear,
$sectionId
);
if ($attendanceScore === null) {
$rawAttendance = $row['ss_attendance_score'] ?? null;
if ($rawAttendance !== null && $rawAttendance !== '') {
$attendanceScore = round((float) $rawAttendance, 2);
}
}
$studentRow = [
'id' => $studentId,
'school_id' => $row['school_id'] ?? null,
'firstname' => $row['firstname'] ?? null,
'lastname' => $row['lastname'] ?? null,
'class_id' => $classIdValue,
'ptap' => $this->normalizeScore($row['ss_ptap_score'] ?? null),
'semester_score' => $this->normalizeScore($row['ss_semester_score'] ?? null),
'attendance' => $attendanceScore,
'homework_avg' => $this->normalizeCountedScore($row['ss_homework_avg'] ?? null, $counts['homework'] ?? [], $sectionId, $studentId),
'project_avg' => $this->normalizeCountedScore($row['ss_project_avg'] ?? null, $counts['project'] ?? [], $sectionId, $studentId),
'quiz_avg' => $this->normalizeCountedScore($row['ss_quiz_avg'] ?? null, $counts['quiz'] ?? [], $sectionId, $studentId),
'participation' => $this->normalizeCountedScore($row['ss_participation_score'] ?? null, $counts['participation'] ?? [], $sectionId, $studentId),
'midterm_exam' => $this->normalizeCountedScore($row['ss_midterm_exam_score'] ?? null, $counts['midterm'] ?? [], $sectionId, $studentId),
'final_exam' => $this->normalizeScore($row['ss_final_exam_score'] ?? null),
'matched_biz_csid' => $row['matched_biz_csid'] ?? null,
'matched_pk_csid' => $row['matched_pk_csid'] ?? null,
'placement_level' => $row['placement_level'] ?? null,
];
$studentsBySection[$sectionId][] = $studentRow;
}
$scoreLocks = $this->loadScoreLocks($grades, $semester, $schoolYear);
return [
'grades' => $grades,
'students_by_section' => $studentsBySection,
'semester' => $semester,
'school_year' => $schoolYear,
'requested_class_id' => $classId,
'semester_options' => $semesterOptions,
'scores_released' => $scoresReleased,
'scores_released_fall' => $scoresReleasedFall,
'scores_released_spring' => $scoresReleasedSpring,
'score_locks' => $scoreLocks,
];
}
private function buildGradingRows(string $semester, string $schoolYear): array
{
$semLower = strtolower(trim($semester));
return DB::table('student_class as sc')
->select([
'cs.id as section_pk',
'cs.class_section_id as section_id',
'cs.class_id as class_id',
'cs.class_section_name',
's.id as student_id',
's.school_id',
's.firstname',
's.lastname',
'pl.level as placement_level',
DB::raw('COALESCE(ss_b.ptap_score, ss_p.ptap_score) as ss_ptap_score'),
DB::raw('COALESCE(ss_b.semester_score, ss_p.semester_score) as ss_semester_score'),
DB::raw('COALESCE(ss_b.attendance_score, ss_p.attendance_score) as ss_attendance_score'),
DB::raw('COALESCE(ss_b.homework_avg, ss_p.homework_avg) as ss_homework_avg'),
DB::raw('COALESCE(ss_b.project_avg, ss_p.project_avg) as ss_project_avg'),
DB::raw('COALESCE(ss_b.quiz_avg, ss_p.quiz_avg) as ss_quiz_avg'),
DB::raw('COALESCE(ss_b.participation_score, ss_p.participation_score) as ss_participation_score'),
DB::raw('COALESCE(ss_b.midterm_exam_score, ss_p.midterm_exam_score) as ss_midterm_exam_score'),
DB::raw('COALESCE(ss_b.final_exam_score, ss_p.final_exam_score) as ss_final_exam_score'),
'ss_b.class_section_id as matched_biz_csid',
'ss_p.class_section_id as matched_pk_csid',
])
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
->join('students as s', 's.id', '=', 'sc.student_id')
->leftJoin('placement_levels as pl', function ($join) use ($schoolYear) {
$join->on('pl.student_id', '=', 's.id')
->where('pl.school_year', '=', $schoolYear);
})
->leftJoin('semester_scores as ss_b', function ($join) use ($semLower, $schoolYear) {
$join->on('ss_b.student_id', '=', 's.id')
->on('ss_b.class_section_id', '=', 'sc.class_section_id')
->whereRaw('LOWER(TRIM(ss_b.semester)) = ?', [$semLower])
->where('ss_b.school_year', '=', $schoolYear);
})
->leftJoin('semester_scores as ss_p', function ($join) use ($semLower, $schoolYear) {
$join->on('ss_p.student_id', '=', 's.id')
->on('ss_p.class_section_id', '=', 'cs.id')
->whereRaw('LOWER(TRIM(ss_p.semester)) = ?', [$semLower])
->where('ss_p.school_year', '=', $schoolYear);
})
->where('sc.school_year', $schoolYear)
->where('s.is_active', 1)
->orderBy('cs.class_id', 'ASC')
->orderBy('cs.class_section_name', 'ASC')
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->get()
->map(fn ($row) => (array) $row)
->all();
}
private function loadScoreCounts(array $rows, string $semester, string $schoolYear): array
{
$sectionIds = [];
$studentIds = [];
foreach ($rows as $row) {
$studentId = (int) ($row['student_id'] ?? 0);
$sectionId = (int) ($row['section_id'] ?? 0);
if ($studentId > 0) {
$studentIds[$studentId] = true;
}
if ($sectionId > 0) {
$sectionIds[$sectionId] = true;
}
}
$sectionIds = array_keys($sectionIds);
$studentIds = array_keys($studentIds);
if (empty($sectionIds) || empty($studentIds)) {
return [
'quiz' => [],
'homework' => [],
'project' => [],
'participation' => [],
'midterm' => [],
];
}
return [
'quiz' => $this->countScores('quiz', $sectionIds, $studentIds, $semester, $schoolYear),
'homework' => $this->countScores('homework', $sectionIds, $studentIds, $semester, $schoolYear),
'project' => $this->countScores('project', $sectionIds, $studentIds, $semester, $schoolYear),
'participation' => $this->countScores('participation', $sectionIds, $studentIds, $semester, $schoolYear),
'midterm' => $this->countScores('midterm_exam', $sectionIds, $studentIds, $semester, $schoolYear),
];
}
private function countScores(string $table, array $sectionIds, array $studentIds, string $semester, string $schoolYear): array
{
$rows = DB::table($table)
->select('student_id', 'class_section_id', DB::raw('COUNT(*) as cnt'))
->where('semester', $semester)
->where('school_year', $schoolYear)
->whereIn('class_section_id', $sectionIds)
->whereIn('student_id', $studentIds)
->whereNotNull('score')
->groupBy('student_id', 'class_section_id')
->get();
$counts = [];
foreach ($rows as $row) {
$counts[(int) $row->class_section_id][(int) $row->student_id] = (int) $row->cnt;
}
return $counts;
}
private function loadScoreLocks(array $grades, string $semester, string $schoolYear): array
{
$sectionIds = [];
foreach ($grades as $sections) {
foreach ($sections as $section) {
$sid = (int) ($section['class_section_id'] ?? 0);
if ($sid > 0) {
$sectionIds[$sid] = true;
}
}
}
$sectionIds = array_keys($sectionIds);
if (empty($sectionIds)) {
return [];
}
$locks = GradingLock::query()
->whereIn('class_section_id', $sectionIds)
->where('semester', $semester)
->where('school_year', $schoolYear)
->get();
$map = [];
foreach ($locks as $lock) {
$map[(int) $lock->class_section_id] = (bool) $lock->is_locked;
}
return $map;
}
private function calculateAttendanceScoreForStudent(int $studentId, string $semester, string $schoolYear, ?int $classSectionId = null): ?float
{
try {
$result = $this->attendanceCalculator->calculate($studentId, $semester, $schoolYear, $classSectionId);
$score = $result['attendance_score'] ?? null;
if ($score === null || $score === '') {
return null;
}
return round((float) $score, 2);
} catch (\Throwable $e) {
}
return null;
}
private function normalizeScore($value): ?float
{
if ($value === null || $value === '') {
return null;
}
return round((float) $value, 2);
}
private function normalizeCountedScore($value, array $countMap, int $sectionId, int $studentId): ?float
{
$score = $this->normalizeScore($value);
if ($score !== null && (float) $score === 0.0) {
$count = (int) ($countMap[$sectionId][$studentId] ?? 0);
if ($count === 0) {
return null;
}
}
return $score;
}
private function resolveSemesterSelection(?string $requestedSemester, array $semesterOptions, ?string $fallbackSemester): string
{
if ($requestedSemester !== null && $requestedSemester !== '') {
foreach ($semesterOptions as $option) {
if (strcasecmp($option, $requestedSemester) === 0) {
return $option;
}
}
return $requestedSemester;
}
if ($fallbackSemester !== null && $fallbackSemester !== '') {
foreach ($semesterOptions as $option) {
if (strcasecmp($option, $fallbackSemester) === 0) {
return $option;
}
}
return $fallbackSemester;
}
return $semesterOptions[0] ?? '';
}
private function getSemestersForSchoolYear(string $schoolYear, ?string $fallbackSemester = null): array
{
$rows = DB::table('semester_scores')
->select(DB::raw('DISTINCT semester'))
->whereNotNull('semester')
->where('semester', '!=', '')
->where('school_year', $schoolYear)
->orderBy('semester', 'ASC')
->get();
$semesters = [];
foreach ($rows as $row) {
$value = trim((string) $row->semester);
if ($value !== '') {
$semesters[] = $value;
}
}
if (empty($semesters)) {
$semesters = ['Fall', 'Spring'];
}
if ($fallbackSemester !== null && $fallbackSemester !== '' && !in_array($fallbackSemester, $semesters, true)) {
array_unshift($semesters, $fallbackSemester);
}
return array_values(array_unique($semesters));
}
private function getParentReleaseKey(string $semester): ?string
{
$norm = strtolower(trim($semester));
if ($norm === 'fall') {
return 'parent_scores_released_fall';
}
if ($norm === 'spring') {
return 'parent_scores_released_spring';
}
return null;
}
private function getParentScoresReleasedForSemester(string $semester): bool
{
$key = $this->getParentReleaseKey($semester);
$raw = $key ? Configuration::getConfig($key) : null;
$raw = (string) ($raw ?? '');
return in_array(strtolower(trim($raw)), ['1', 'true', 'yes', 'y', 'on'], true);
}
private function ensureParentReleaseKeyExists(string $semester): void
{
$key = $this->getParentReleaseKey($semester);
if (!$key) {
return;
}
if (Configuration::getConfigValueByKey($key) === null) {
Configuration::setConfigValueByKey($key, '0');
}
}
}
@@ -0,0 +1,372 @@
<?php
namespace App\Services\Grading;
use App\Models\ClassSection;
use App\Models\PlacementBatch;
use App\Models\PlacementLevel;
use App\Models\PlacementScore;
use App\Models\Student;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class GradingPlacementService
{
public function placementContext(?int $classSectionId, string $schoolYear, ?string $placementTest, ?string $open): array
{
if (!$classSectionId) {
$showStudents = ($placementTest !== '' && $open === '1');
$students = $showStudents ? $this->fetchActiveStudentsWithSection($schoolYear) : [];
$batches = $this->fetchPlacementBatches($schoolYear);
$batchDetails = $this->fetchPlacementBatchDetails($batches, $schoolYear);
return [
'school_year' => $schoolYear,
'students' => $students,
'batches' => $batches,
'batch_details' => $batchDetails,
'placement_test' => $placementTest,
'show_students' => $showStudents,
];
}
$sectionName = ClassSection::query()->where('class_section_id', $classSectionId)->value('class_section_name');
$classId = ClassSection::query()->where('class_section_id', $classSectionId)->value('class_id');
$students = Student::getStudentInfoByClassSectionId($classSectionId, null, $schoolYear);
$studentIds = array_values(array_filter(array_map(
static fn ($row) => (int) ($row['student_id'] ?? 0),
$students
), static fn ($id) => $id > 0));
$levels = [];
if (!empty($studentIds)) {
$rows = PlacementLevel::query()
->whereIn('student_id', $studentIds)
->where('school_year', $schoolYear)
->get();
foreach ($rows as $row) {
$levels[(int) $row->student_id] = $row->level ?? null;
}
}
return [
'class_section_id' => $classSectionId,
'class_section_name' => $sectionName ?? '',
'class_id' => $classId,
'school_year' => $schoolYear,
'students' => $students,
'levels' => $levels,
];
}
public function updatePlacementLevel(int $studentId, string $schoolYear, $level, ?int $userId): void
{
if ($studentId <= 0 || $schoolYear === '') {
throw new RuntimeException('Missing student or school year.');
}
$level = $level === '' || $level === null ? null : (int) $level;
if ($level !== null && !in_array($level, [1, 2, 3], true)) {
throw new RuntimeException('Invalid placement level.');
}
$existing = PlacementLevel::query()
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->first();
if ($level === null) {
if ($existing) {
$existing->delete();
}
return;
}
$payload = [
'student_id' => $studentId,
'school_year' => $schoolYear,
'level' => $level,
'updated_by' => $userId,
];
if ($existing) {
$existing->update($payload);
} else {
$payload['created_by'] = $userId;
PlacementLevel::query()->create($payload);
}
}
public function updatePlacementLevels(int $classSectionId, string $schoolYear, array $levels, ?int $userId): void
{
if ($classSectionId <= 0 || $schoolYear === '') {
throw new RuntimeException('Missing class section or school year.');
}
$students = Student::getStudentInfoByClassSectionId($classSectionId, null, $schoolYear);
$validIds = array_values(array_filter(array_map(
static fn ($row) => (int) ($row['student_id'] ?? 0),
$students
), static fn ($id) => $id > 0));
$validSet = array_flip($validIds);
$existingRows = [];
if (!empty($validIds)) {
$rows = PlacementLevel::query()
->whereIn('student_id', $validIds)
->where('school_year', $schoolYear)
->get();
foreach ($rows as $row) {
$existingRows[(int) $row->student_id] = $row;
}
}
foreach ($levels as $studentIdRaw => $levelRaw) {
$studentId = (int) $studentIdRaw;
if (!isset($validSet[$studentId])) {
continue;
}
$levelRaw = trim((string) $levelRaw);
$level = $levelRaw === '' ? null : (int) $levelRaw;
if ($level !== null && !in_array($level, [1, 2, 3], true)) {
continue;
}
if ($level === null) {
if (isset($existingRows[$studentId])) {
$existingRows[$studentId]->delete();
}
continue;
}
$payload = [
'student_id' => $studentId,
'school_year' => $schoolYear,
'level' => $level,
'updated_by' => $userId,
];
if (isset($existingRows[$studentId])) {
$existingRows[$studentId]->update($payload);
} else {
$payload['created_by'] = $userId;
PlacementLevel::query()->create($payload);
}
}
}
public function createPlacementBatch(string $schoolYear, string $placementTest, array $levels, ?int $userId): int
{
if ($schoolYear === '' || $placementTest === '') {
throw new RuntimeException('Missing placement test or school year.');
}
$students = $this->fetchActiveStudentsWithSection($schoolYear);
$validIds = array_values(array_filter(array_map(
static fn ($row) => (int) ($row['student_id'] ?? 0),
$students
), static fn ($id) => $id > 0));
$validSet = array_flip($validIds);
$batch = PlacementBatch::query()->create([
'placement_test' => $placementTest,
'school_year' => $schoolYear,
'created_by' => $userId,
'updated_by' => $userId,
]);
$savedCount = 0;
foreach ($levels as $studentIdRaw => $levelRaw) {
$studentId = (int) $studentIdRaw;
if (!isset($validSet[$studentId])) {
continue;
}
$levelRaw = trim((string) $levelRaw);
if ($levelRaw === '') {
continue;
}
$score = (int) $levelRaw;
if ($score < 0 || $score > 100) {
continue;
}
PlacementScore::query()->create([
'batch_id' => (int) $batch->id,
'student_id' => $studentId,
'score' => $score,
'created_by' => $userId,
'updated_by' => $userId,
]);
$savedCount++;
}
if ($savedCount === 0) {
$batch->delete();
throw new RuntimeException('No scores entered. Batch not saved.');
}
return (int) $batch->id;
}
public function getPlacementBatch(int $batchId): array
{
$batch = PlacementBatch::query()->find($batchId);
if (!$batch) {
throw new RuntimeException('Placement batch not found.');
}
$schoolYear = (string) ($batch->school_year ?? '');
$students = $this->fetchActiveStudentsWithSection($schoolYear);
$scores = $this->fetchPlacementScoresForBatch($batchId);
return [
'batch' => $batch,
'students' => $students,
'scores' => $scores,
];
}
public function updatePlacementBatch(int $batchId, string $schoolYear, array $levels, ?int $userId): void
{
$batch = PlacementBatch::query()->find($batchId);
if (!$batch) {
throw new RuntimeException('Placement batch not found.');
}
$students = $this->fetchActiveStudentsWithSection($schoolYear);
$validIds = array_values(array_filter(array_map(
static fn ($row) => (int) ($row['student_id'] ?? 0),
$students
), static fn ($id) => $id > 0));
$validSet = array_flip($validIds);
$existing = $this->fetchPlacementScoresForBatch($batchId);
foreach ($levels as $studentIdRaw => $scoreRaw) {
$studentId = (int) $studentIdRaw;
if (!isset($validSet[$studentId])) {
continue;
}
$scoreRaw = trim((string) $scoreRaw);
if ($scoreRaw === '') {
if (isset($existing[$studentId])) {
PlacementScore::query()->whereKey($existing[$studentId]['id'])->delete();
}
continue;
}
$score = (int) $scoreRaw;
if ($score < 0 || $score > 100) {
continue;
}
if (isset($existing[$studentId])) {
PlacementScore::query()->whereKey($existing[$studentId]['id'])->update([
'score' => $score,
'updated_by' => $userId,
]);
} else {
PlacementScore::query()->create([
'batch_id' => $batchId,
'student_id' => $studentId,
'score' => $score,
'created_by' => $userId,
'updated_by' => $userId,
]);
}
}
$batch->update([
'updated_by' => $userId,
]);
}
private function fetchActiveStudentsWithSection(string $schoolYear): array
{
return DB::table('students as s')
->select('s.id as student_id', 's.school_id', 's.firstname', 's.lastname', 'sc.class_section_id', 'cs.class_section_name', 'c.class_name')
->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('classes as c', 'c.id', '=', 'cs.class_id')
->where('s.is_active', 1)
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->get()
->map(fn ($row) => (array) $row)
->all();
}
private function fetchPlacementBatches(string $schoolYear): array
{
return PlacementBatch::query()
->where('school_year', $schoolYear)
->orderBy('created_at', 'DESC')
->get()
->map(fn ($row) => (array) $row)
->all();
}
private function fetchPlacementBatchDetails(array $batches, string $schoolYear): array
{
if (empty($batches)) {
return [];
}
$batchIds = array_values(array_filter(array_map(
static fn ($row) => (int) ($row['id'] ?? 0),
$batches
), static fn ($id) => $id > 0));
if (empty($batchIds)) {
return [];
}
$rows = DB::table('placement_scores as ps')
->select('ps.batch_id', 'ps.score', 's.school_id', 's.firstname', 's.lastname', 'cs.class_section_name', 'c.class_name')
->join('students as s', 's.id', '=', 'ps.student_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('classes as c', 'c.id', '=', 'cs.class_id')
->whereIn('ps.batch_id', $batchIds)
->where('s.is_active', 1)
->orderBy('ps.batch_id', 'ASC')
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->get()
->map(fn ($row) => (array) $row)
->all();
$details = [];
foreach ($rows as $row) {
$bid = (int) ($row['batch_id'] ?? 0);
if ($bid <= 0) {
continue;
}
$details[$bid][] = $row;
}
return $details;
}
private function fetchPlacementScoresForBatch(int $batchId): array
{
$rows = PlacementScore::query()
->where('batch_id', $batchId)
->get()
->map(fn ($row) => (array) $row)
->all();
$scores = [];
foreach ($rows as $row) {
$scores[(int) $row['student_id']] = $row;
}
return $scores;
}
}
@@ -0,0 +1,111 @@
<?php
namespace App\Services\Grading;
use App\Models\ScoreComment;
use App\Models\SemesterScore;
use App\Models\Student;
use App\Services\Scores\SemesterScoreService;
use RuntimeException;
class GradingRefreshService
{
public function __construct(private SemesterScoreService $semesterScoreService)
{
}
public function refreshSemesterScores(int $classSectionId, string $semester, string $schoolYear): void
{
if ($classSectionId <= 0) {
throw new RuntimeException('Missing class section.');
}
$studentTeacherInfo = Student::getStudentInfoByClassSectionId(
$classSectionId,
$semester,
$schoolYear
);
if (empty($studentTeacherInfo)) {
throw new RuntimeException('No students found for this class/term.');
}
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
$this->refreshAttendanceComments($classSectionId, $semester, $schoolYear);
}
private function refreshAttendanceComments(int $classSectionId, string $semester, string $schoolYear): void
{
if (!function_exists('attendance_comment_from_score')) {
return;
}
$scoreRows = SemesterScore::query()
->select(['student_id', 'attendance_score'])
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->get();
if ($scoreRows->isEmpty()) {
return;
}
$studentIds = $scoreRows->pluck('student_id')->map(fn ($v) => (int) $v)->unique()->values()->all();
if (empty($studentIds)) {
return;
}
$students = Student::query()
->select(['id', 'firstname'])
->whereIn('id', $studentIds)
->get();
$nameMap = [];
foreach ($students as $student) {
$nameMap[(int) $student->id] = (string) ($student->firstname ?? '');
}
$existing = ScoreComment::query()
->where('score_type', 'attendance')
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->whereIn('student_id', $studentIds)
->get();
$existingByStudent = [];
foreach ($existing as $row) {
$existingByStudent[(int) $row->student_id] = $row;
}
foreach ($scoreRows as $row) {
$studentId = (int) $row->student_id;
if ($studentId <= 0) {
continue;
}
$score = $row->attendance_score !== null ? (float) $row->attendance_score : null;
if ($score === null) {
continue;
}
$auto = attendance_comment_from_score($score, $nameMap[$studentId] ?? '');
if ($auto === null) {
continue;
}
if (isset($existingByStudent[$studentId])) {
$existingByStudent[$studentId]->update([
'comment' => $auto,
]);
} else {
ScoreComment::query()->create([
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'score_type' => 'attendance',
'semester' => $semester,
'school_year' => $schoolYear,
'comment' => $auto,
'commented_by' => null,
'created_at' => now(),
]);
}
}
}
}
@@ -0,0 +1,189 @@
<?php
namespace App\Services\Grading;
use App\Models\FinalExam;
use App\Models\GradingLock;
use App\Models\Homework;
use App\Models\MidtermExam;
use App\Models\Project;
use App\Models\Quiz;
use App\Models\ScoreComment;
use App\Models\SemesterScore;
use App\Models\Student;
use App\Services\Scores\SemesterScoreService;
use RuntimeException;
class GradingScoreService
{
public function __construct(private SemesterScoreService $semesterScoreService)
{
}
public function show(string $type, int $classSectionId, int $studentId, string $semester, string $schoolYear): array
{
$model = $this->resolveModel($type);
$student = Student::query()->find($studentId);
if (!$student) {
throw new RuntimeException('Student not found.');
}
$scores = $model->newQuery()
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->get();
$scoresLocked = $classSectionId > 0
? GradingLock::isLocked($classSectionId, $semester, $schoolYear)
: false;
return [
'student' => $student,
'scores' => $scores,
'type' => $type,
'class_section_id' => $classSectionId,
'semester' => $semester,
'scores_locked' => $scoresLocked,
];
}
public function update(array $payload, int $updatedBy): void
{
$type = (string) ($payload['type'] ?? '');
$studentId = (int) ($payload['student_id'] ?? 0);
$classSectionId = (int) ($payload['class_section_id'] ?? 0);
$semester = (string) ($payload['semester'] ?? '');
$schoolYear = (string) ($payload['school_year'] ?? '');
if ($classSectionId > 0 && GradingLock::isLocked($classSectionId, $semester, $schoolYear)) {
throw new RuntimeException('Scores are locked for this class. Unlock to edit.');
}
$model = $this->resolveModel($type);
if (in_array($type, ['homework', 'quiz', 'project'], true)) {
$scoreIds = $payload['score_ids'] ?? [];
$scores = $payload['scores'] ?? [];
$comments = $payload['comments'] ?? [];
foreach ($scoreIds as $i => $id) {
$model->newQuery()->whereKey($id)->update([
'score' => $scores[$i] ?? null,
'comment' => $comments[$i] ?? null,
'updated_at' => now(),
]);
}
} elseif (in_array($type, ['midterm', 'final', 'test'], true)) {
$score = $payload['score'] ?? null;
$data = [
'score' => $score,
'updated_at' => now(),
];
$existing = $model->newQuery()
->where('student_id', $studentId)
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
if ($existing) {
$existing->update($data);
} else {
$model->newQuery()->create($data + [
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'semester' => $semester,
'school_year' => $schoolYear,
'created_at' => now(),
]);
}
} elseif ($type === 'comments') {
$comment = (string) ($payload['comment'] ?? '');
$model->newQuery()
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->delete();
$model->newQuery()->create([
'student_id' => $studentId,
'score_type' => 'general',
'semester' => $semester,
'school_year' => $schoolYear,
'comment' => $comment,
'commented_by' => $updatedBy ?: null,
'created_at' => now(),
]);
}
$studentTeacherInfo = Student::getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear, $updatedBy);
if (!empty($studentTeacherInfo)) {
try {
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
} catch (RuntimeException $e) {
}
}
}
public function getScoreComments(string $semester, string $schoolYear): array
{
$studentIds = Student::query()
->select('students.id')
->join('student_class', 'student_class.student_id', '=', 'students.id')
->where('student_class.semester', $semester)
->where('student_class.school_year', $schoolYear)
->pluck('students.id')
->map(fn ($v) => (int) $v)
->all();
if (empty($studentIds)) {
return [];
}
$scores = [];
$students = Student::query()
->whereIn('id', $studentIds)
->where('school_year', $schoolYear)
->get();
foreach ($students as $student) {
$scores[$student->id] = [
'school_id' => $student->school_id,
'firstname' => $student->firstname,
'lastname' => $student->lastname,
'comments' => [],
];
}
$commentRows = ScoreComment::query()
->whereIn('student_id', $studentIds)
->where('semester', $semester)
->where('school_year', $schoolYear)
->get();
foreach ($commentRows as $comment) {
$scores[$comment->student_id]['comments'][] = $comment;
}
return $scores;
}
private function resolveModel(string $type)
{
return match ($type) {
'homework' => new Homework(),
'quiz' => new Quiz(),
'project' => new Project(),
'midterm' => new MidtermExam(),
'final' => new FinalExam(),
'test' => new SemesterScore(),
'comments' => new ScoreComment(),
default => throw new RuntimeException("Invalid type: {$type}"),
};
}
}
@@ -0,0 +1,86 @@
<?php
namespace App\Services\Grading;
use App\Models\CalendarEvent;
use DateTime;
class HomeworkTrackingCalendarService
{
public function buildDateRange(string $schoolYear, string $semester): array
{
$startYear = null;
if (preg_match('/\b(20\d{2})\b/', $schoolYear, $m)) {
$startYear = (int) $m[1];
}
if ($startYear === null) {
$today = new DateTime('today');
return [$today, $today];
}
$nextYear = $startYear + 1;
$sem = strtolower(trim($semester));
try {
if ($sem === 'spring') {
$start = new DateTime("{$nextYear}-01-25");
$end = new DateTime("{$nextYear}-05-31");
} else {
$start = new DateTime("{$startYear}-09-21");
$end = new DateTime("{$nextYear}-01-18");
}
} catch (\Throwable $e) {
$today = new DateTime('today');
return [$today, $today];
}
return [$start, $end];
}
public function buildSundays(DateTime $start, DateTime $end): array
{
if ((int) $start->format('w') !== 0) {
$start = (clone $start)->modify('next sunday');
}
$sundays = [];
$d = clone $start;
while ($d <= $end) {
$sundays[] = $d->format('Y-m-d');
$d->modify('+7 days');
}
return $sundays;
}
public function eventDays(string $schoolYear): array
{
$events = CalendarEvent::getEventsBySchoolYear($schoolYear) ?? [];
$eventDays = [];
foreach ($events as $event) {
$ymd = substr((string) ($event['date'] ?? ''), 0, 10);
$no = (int) ($event['no_school'] ?? 0);
if ($ymd && $no === 1) {
$eventDays[$ymd] = true;
}
}
return $eventDays;
}
public function dateToIndex(array $sundays, array $eventDays): array
{
$dateToIndex = [];
$idx = 0;
foreach ($sundays as $ymd) {
if (!isset($eventDays[$ymd])) {
$idx++;
$dateToIndex[$ymd] = $idx;
} else {
$dateToIndex[$ymd] = null;
}
}
return $dateToIndex;
}
}
@@ -0,0 +1,219 @@
<?php
namespace App\Services\Grading;
use App\Models\Configuration;
use Illuminate\Support\Facades\DB;
class HomeworkTrackingService
{
private array $teacherAssignmentCache = [];
public function __construct(private HomeworkTrackingCalendarService $calendarService)
{
}
public function report(?string $semester = null, ?string $schoolYear = null, int $page = 1): array
{
$semester = $semester ?: (string) (Configuration::getConfig('semester') ?? '');
$schoolYear = $schoolYear ?: (string) (Configuration::getConfig('school_year') ?? '');
[$start, $end] = $this->calendarService->buildDateRange($schoolYear, $semester);
$sundays = $this->calendarService->buildSundays($start, $end);
$eventDays = $this->calendarService->eventDays($schoolYear);
$dateToIndex = $this->calendarService->dateToIndex($sundays, $eventDays);
$limitToSemester = $this->hasTeacherAssignments($schoolYear);
$homeworkByIndex = $this->loadHomeworkByIndex($schoolYear, $semester, $limitToSemester);
$homeworkByDate = $this->loadHomeworkByDate($schoolYear, $semester, $limitToSemester, $sundays, $eventDays);
$teachers = $this->loadTeachers($schoolYear);
$perPage = 8;
$totalRows = count($teachers);
$totalPages = max(1, (int) ceil($totalRows / $perPage));
$page = max(1, min($page, $totalPages));
$offset = ($page - 1) * $perPage;
$teachersPage = array_slice($teachers, $offset, $perPage);
return [
'semester' => $semester,
'school_year' => $schoolYear,
'sundays' => $sundays,
'event_days' => $eventDays,
'date_to_index' => $dateToIndex,
'teachers' => $teachersPage,
'has_homework' => $homeworkByIndex['has_homework'],
'hw_entered_at' => $homeworkByIndex['entered_at'],
'has_homework_by_date' => $homeworkByDate['has_homework'],
'hw_entered_at_by_date' => $homeworkByDate['entered_at'],
'page' => $page,
'total_pages' => $totalPages,
'per_page' => $perPage,
'total_rows' => $totalRows,
];
}
private function hasTeacherAssignments(string $schoolYear): bool
{
$year = trim((string) $schoolYear);
if ($year === '') {
return false;
}
if (array_key_exists($year, $this->teacherAssignmentCache)) {
return $this->teacherAssignmentCache[$year];
}
$cnt = DB::table('teacher_class')->where('school_year', $year)->count();
$this->teacherAssignmentCache[$year] = $cnt > 0;
return $this->teacherAssignmentCache[$year];
}
private function loadHomeworkByIndex(string $schoolYear, string $semester, bool $limitToSemester): array
{
$query = DB::table('homework')
->select('class_section_id', 'homework_index', DB::raw('MIN(created_at) as first_created'), DB::raw('COUNT(*) as cnt'))
->where('school_year', $schoolYear);
if ($limitToSemester && $semester !== '') {
$query->where('semester', $semester);
}
$rows = $query->groupBy('class_section_id', 'homework_index')->get();
$hasHomework = [];
$enteredAt = [];
foreach ($rows as $row) {
$csid = (int) $row->class_section_id;
$hi = (int) $row->homework_index;
$cnt = (int) $row->cnt;
if ($csid > 0 && $hi > 0 && $cnt > 0) {
$hasHomework[$csid][$hi] = true;
$dateStr = substr((string) ($row->first_created ?? ''), 0, 10);
$enteredAt[$csid][$hi] = $dateStr ?: null;
}
}
return ['has_homework' => $hasHomework, 'entered_at' => $enteredAt];
}
private function loadHomeworkByDate(string $schoolYear, string $semester, bool $limitToSemester, array $sundays, array $eventDays): array
{
$query = DB::table('homework')
->select(DB::raw('class_section_id, DATE(created_at) as hw_date, MIN(created_at) as first_created, COUNT(*) as cnt'))
->where('school_year', $schoolYear);
if ($limitToSemester && $semester !== '') {
$query->where('semester', $semester);
}
$rows = $query
->groupBy('class_section_id', DB::raw('DATE(created_at)'))
->orderBy('hw_date', 'ASC')
->get();
$hasHomework = [];
$enteredAt = [];
foreach ($rows as $row) {
$csid = (int) $row->class_section_id;
$date = substr((string) ($row->hw_date ?? ''), 0, 10);
$cnt = (int) $row->cnt;
$firstCreated = substr((string) ($row->first_created ?? ''), 0, 10);
if ($csid <= 0 || !$date || $cnt <= 0) {
continue;
}
$baseIndex = -1;
for ($i = count($sundays) - 1; $i >= 0; $i--) {
if ($sundays[$i] <= $date) {
$baseIndex = $i;
break;
}
}
if ($baseIndex < 0) {
continue;
}
$j = $baseIndex;
while ($j >= 0 && !empty($eventDays[$sundays[$j]])) {
$j--;
}
if ($j < 0) {
continue;
}
$sunday = $sundays[$j];
$hasHomework[$csid][$sunday] = true;
$candidate = $firstCreated ?: $date;
if (empty($enteredAt[$csid][$sunday]) || ($candidate && $candidate < $enteredAt[$csid][$sunday])) {
$enteredAt[$csid][$sunday] = $candidate;
}
}
return ['has_homework' => $hasHomework, 'entered_at' => $enteredAt];
}
private function loadTeachers(string $schoolYear): array
{
$rows = DB::table('teacher_class as tc')
->select('tc.class_section_id', 'tc.position', 'cs.class_section_name', 'cs.class_id', 'u.firstname', 'u.lastname')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'tc.class_section_id')
->leftJoin('users as u', 'u.id', '=', 'tc.teacher_id')
->where('tc.school_year', $schoolYear)
->whereNotNull('tc.class_section_id')
->whereIn('tc.position', ['main', 'ta'])
->orderBy('cs.class_id', 'ASC')
->orderBy('cs.class_section_name', 'ASC')
->orderByRaw("FIELD(tc.position, 'main','ta')")
->orderBy('u.firstname', 'ASC')
->get();
$bySection = [];
foreach ($rows as $row) {
$sid = (int) $row->class_section_id;
if ($sid <= 0) {
continue;
}
if (!isset($bySection[$sid])) {
$bySection[$sid] = [
'class_section_id' => $sid,
'class_id' => (int) ($row->class_id ?? 0),
'class_section_name' => (string) ($row->class_section_name ?? ''),
'teachers' => [],
'tas' => [],
];
}
$name = trim((string) ($row->firstname ?? '') . ' ' . (string) ($row->lastname ?? ''));
$pos = strtolower((string) ($row->position ?? ''));
if ($name !== '') {
if ($pos === 'main') {
$bySection[$sid]['teachers'][] = $name;
} elseif ($pos === 'ta') {
$bySection[$sid]['tas'][] = $name;
}
}
}
$teachers = array_values($bySection);
usort($teachers, function ($a, $b) {
$ai = (int) ($a['class_id'] ?? 0);
$bi = (int) ($b['class_id'] ?? 0);
$order = function (int $cid): int {
if ($cid === 13) return 0;
if ($cid >= 1 && $cid <= 11) return $cid;
if ($cid === 12) return 99;
return 200 + $cid;
};
$cmp = $order($ai) <=> $order($bi);
if ($cmp !== 0) {
return $cmp;
}
return strnatcasecmp($a['class_section_name'] ?? '', $b['class_section_name'] ?? '');
});
return $teachers;
}
}