hasVerificationTokenColumn = Schema::hasTable('certificate_records') && Schema::hasColumn('certificate_records', 'verification_token'); } public function dashboard(?string $schoolYear = null): array { $selectedYear = $this->resolveSchoolYear($schoolYear); $allEnrolled = DB::table('student_class as sc') ->select( 's.id as student_id', 's.firstname', 's.lastname', 'sc.class_section_id', 'cs.class_section_name' ) ->join('students as s', 's.id', '=', 'sc.student_id') ->join('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id') ->where('s.is_active', 1) ->where('sc.school_year', $selectedYear) ->orderBy('s.firstname', 'ASC') ->orderBy('s.lastname', 'ASC') ->get() ->map(fn ($row) => (array) $row) ->all(); $studentIds = array_values(array_unique(array_map( static fn (array $row): int => (int) ($row['student_id'] ?? 0), $allEnrolled ))); $studentIds = array_values(array_filter($studentIds, static fn (int $id): bool => $id > 0)); $decisionsByStudent = []; if ($studentIds !== []) { $decisionRows = StudentDecision::query() ->whereIn('student_id', $studentIds) ->where('school_year', $selectedYear) ->get(); foreach ($decisionRows as $decisionRow) { $sid = (int) ($decisionRow->student_id ?? 0); if ($sid <= 0) { continue; } $decision = trim((string) ($decisionRow->decision ?? '')); $source = trim((string) ($decisionRow->source ?? '')); if ($source === '') { $source = $decision === '' ? 'pending' : 'manual'; } $decisionsByStudent[$sid][] = [ 'decision' => $decision, 'source' => $source, 'notes' => (string) ($decisionRow->notes ?? ''), 'year_score' => is_numeric($decisionRow->year_score) ? round((float) $decisionRow->year_score, 2) : null, ]; } } $certsByStudent = []; if ($studentIds !== []) { $certRows = DB::table('certificate_records') ->select('student_id', 'certificate_number', 'issued_at') ->whereIn('student_id', $studentIds) ->where('school_year', $selectedYear) ->orderByDesc('issued_at') ->get(); foreach ($certRows as $record) { $sid = (int) ($record->student_id ?? 0); if ($sid > 0 && ! isset($certsByStudent[$sid])) { $certsByStudent[$sid] = (string) ($record->certificate_number ?? ''); } } } $statsPerClass = []; $studentsByClass = []; foreach ($allEnrolled as $row) { $sid = (int) ($row['student_id'] ?? 0); $classSectionId = (int) ($row['class_section_id'] ?? 0); $classSectionName = (string) ($row['class_section_name'] ?? ('Section '.$classSectionId)); if (! isset($statsPerClass[$classSectionId])) { $statsPerClass[$classSectionId] = [ 'name' => $classSectionName, 'total' => 0, 'pass' => 0, 'cert' => 0, ]; } $statsPerClass[$classSectionId]['total']++; $studentDecisionRows = $decisionsByStudent[$sid] ?? []; $decisionState = $this->normalizeDecisionState($studentDecisionRows); if ($decisionState['eligible']) { $statsPerClass[$classSectionId]['pass']++; } $certificateNumber = $certsByStudent[$sid] ?? null; if ($certificateNumber !== null && $certificateNumber !== '') { $statsPerClass[$classSectionId]['cert']++; } $studentsByClass[$classSectionId][] = [ 'student_id' => $sid, 'firstname' => (string) ($row['firstname'] ?? ''), 'lastname' => (string) ($row['lastname'] ?? ''), 'class_section_id' => $classSectionId, 'class_section_name' => $classSectionName, 'year_score' => $decisionState['year_score'], 'eligible' => $decisionState['eligible'], 'decision_state' => $decisionState['status'], 'decision_labels' => $decisionState['display_decisions'], 'decision_rows' => $studentDecisionRows, 'certificate_number' => $certificateNumber, ]; } $gradeGroups = $this->buildGradeGroups($statsPerClass, $studentsByClass); return [ 'school_year' => $selectedYear, 'cert_date' => date('m/d/Y'), 'grade_groups' => array_values($gradeGroups), 'stats_per_class' => $statsPerClass, 'default_group_key' => array_key_first($gradeGroups), ]; } public function auditLog(?string $schoolYear = null): array { $selectedYear = trim((string) ($schoolYear ?? '')); $recordsQuery = DB::table('certificate_records as cr') ->select( 'cr.*', 'u.firstname as admin_firstname', 'u.lastname as admin_lastname' ) ->leftJoin('users as u', 'u.id', '=', 'cr.issued_by') ->orderByDesc('cr.issued_at'); if ($selectedYear !== '') { $recordsQuery->where('cr.school_year', $selectedYear); } $records = $recordsQuery->get()->map(fn ($row) => (array) $row)->all(); $yearSummary = DB::table('certificate_records') ->select('school_year', DB::raw('COUNT(*) as total')) ->groupBy('school_year') ->orderBy('school_year', 'DESC') ->get() ->map(fn ($row) => (array) $row) ->all(); return [ 'school_year' => $selectedYear, 'records' => $records, 'year_summary' => $yearSummary, ]; } /** * @param list $studentIds * @return array{students: list>, cert_date_display: string, school_year: string} */ public function issueCertificates( array $studentIds, string $certDate, ?int $classSectionId, ?string $schoolYear, ?int $issuedBy ): array { $studentIds = array_values(array_filter(array_map('intval', $studentIds), static fn (int $id): bool => $id > 0)); if ($studentIds === []) { throw new \InvalidArgumentException('Please select at least one student.'); } $selectedYear = $this->resolveSchoolYear($schoolYear); $certDateDisplay = $this->normalizeDisplayCertDate($certDate); $certDateStorage = $this->parseCertDate($certDateDisplay); $students = $this->fetchStudentsForIssuance($studentIds, $classSectionId, $selectedYear); if ($students === []) { throw new \RuntimeException('No valid students found.'); } $rosterMap = []; foreach ($students as $student) { $rosterMap[(int) $student['id']] = $student; } $existingByStudent = DB::table('certificate_records') ->whereIn('student_id', array_keys($rosterMap)) ->where('school_year', $selectedYear) ->orderByDesc('issued_at') ->get() ->map(fn ($row) => (array) $row) ->groupBy('student_id') ->map(fn ($rows) => $rows[0]) ->all(); $baseCount = (int) DB::table('certificate_records') ->where('school_year', $selectedYear) ->count(); $nextOffset = 0; DB::beginTransaction(); try { foreach ($students as &$student) { $sid = (int) ($student['id'] ?? 0); $existing = $existingByStudent[$sid] ?? null; if (is_array($existing)) { $student['cert_number'] = (string) ($existing['certificate_number'] ?? ''); $student['verify_token'] = $this->ensureVerificationToken($existing); } else { $nextOffset++; $certificateNumber = $this->formatCertificateNumber($selectedYear, $baseCount + $nextOffset); $verificationToken = $this->hasVerificationTokenColumn ? $this->generateVerificationToken() : null; $grade = $this->formatGrade((string) ($student['grade'] ?? '')); $studentName = trim((string) ($student['firstname'] ?? '').' '.(string) ($student['lastname'] ?? '')); $payload = [ 'certificate_number' => $certificateNumber, 'student_id' => $sid, 'student_name' => $studentName, 'grade' => $grade, 'cert_date' => $certDateStorage, 'school_year' => $selectedYear, 'class_section_id' => $classSectionId, 'issued_by' => $issuedBy, 'issued_at' => now(), 'created_at' => now(), 'updated_at' => now(), ]; if ($this->hasVerificationTokenColumn) { $payload['verification_token'] = $verificationToken; } DB::table('certificate_records')->insert($payload); $student['cert_number'] = $certificateNumber; $student['verify_token'] = $verificationToken; } $student['grade'] = $this->formatGrade((string) ($student['grade'] ?? '')); $student['verify_url'] = ! empty($student['verify_token']) ? url('/api/certificates/verify/'.rawurlencode((string) $student['verify_token'])) : null; } unset($student); DB::commit(); } catch (\Throwable $e) { DB::rollBack(); throw $e; } return [ 'students' => $students, 'cert_date_display' => $certDateDisplay, 'school_year' => $selectedYear, ]; } /** * @return array{student: array, cert_date_display: string}|null */ public function reprintPayload(string $certificateNumber): ?array { $record = DB::table('certificate_records') ->where('certificate_number', strtoupper(trim($certificateNumber))) ->first(); if (! $record) { return null; } $row = (array) $record; $studentName = trim((string) ($row['student_name'] ?? '')); [$firstname, $lastname] = $this->splitStudentName($studentName); $verifyToken = $this->ensureVerificationToken($row); return [ 'student' => [ 'id' => (int) ($row['student_id'] ?? 0), 'firstname' => $firstname, 'lastname' => $lastname, 'grade' => (string) ($row['grade'] ?? ''), 'cert_number' => (string) ($row['certificate_number'] ?? ''), 'verify_token' => $verifyToken, 'verify_url' => $verifyToken !== '' ? url('/api/certificates/verify/'.rawurlencode($verifyToken)) : null, ], 'cert_date_display' => $this->displayDateFromStorage($row['cert_date'] ?? null), ]; } /** * @return array|null */ public function verifyPayload(string $tokenOrNumber): ?array { $lookup = strtoupper(trim($tokenOrNumber)); $query = DB::table('certificate_records as cr') ->select( 'cr.certificate_number', 'cr.student_name', 'cr.grade', 'cr.cert_date', 'cr.school_year', 'cr.issued_at', 'u.firstname as admin_firstname', 'u.lastname as admin_lastname' ) ->leftJoin('users as u', 'u.id', '=', 'cr.issued_by'); if ($this->hasVerificationTokenColumn) { $query->where(function ($builder) use ($tokenOrNumber, $lookup): void { $builder->where('cr.verification_token', trim($tokenOrNumber)) ->orWhere('cr.certificate_number', $lookup); }); } else { $query->where('cr.certificate_number', $lookup); } $record = $query->first(); if (! $record) { return null; } return (array) $record; } private function resolveSchoolYear(?string $schoolYear): string { $selectedYear = trim((string) ($schoolYear ?? '')); if ($selectedYear !== '') { return $selectedYear; } return trim((string) (Configuration::getConfig('school_year') ?? '')); } /** * @param array $statsPerClass * @param array>> $studentsByClass * @return array> */ private function buildGradeGroups(array $statsPerClass, array $studentsByClass): array { $gradeGroups = []; foreach ($statsPerClass as $classSectionId => $stats) { [$label, $sortKey, $slug] = $this->classGroupMeta((string) ($stats['name'] ?? '')); if (! isset($gradeGroups[$sortKey])) { $gradeGroups[$sortKey] = [ 'key' => $sortKey, 'label' => $label, 'slug' => $slug, 'total' => 0, 'pass' => 0, 'cert' => 0, 'sections' => [], ]; } $total = (int) ($stats['total'] ?? 0); $pass = (int) ($stats['pass'] ?? 0); $cert = (int) ($stats['cert'] ?? 0); $gradeGroups[$sortKey]['total'] += $total; $gradeGroups[$sortKey]['pass'] += $pass; $gradeGroups[$sortKey]['cert'] += $cert; $gradeGroups[$sortKey]['sections'][] = [ 'section_id' => (int) $classSectionId, 'section_name' => (string) ($stats['name'] ?? ''), 'student_count' => $total, 'pass_count' => $pass, 'cert_count' => $cert, 'remaining_count' => max(0, $pass - $cert), 'students' => $studentsByClass[$classSectionId] ?? [], ]; } ksort($gradeGroups); foreach ($gradeGroups as &$group) { $group['fully_done'] = $group['pass'] > 0 && $group['cert'] >= $group['pass']; $group['has_pass'] = $group['pass'] > 0; } unset($group); return $gradeGroups; } /** * @param list> $studentDecisionRows * @return array{eligible: bool, status: string, display_decisions: list, year_score: float|null} */ private function normalizeDecisionState(array $studentDecisionRows): array { if ($studentDecisionRows === []) { return [ 'eligible' => false, 'status' => 'pending', 'display_decisions' => [], 'year_score' => null, ]; } $allDecisions = array_map( static fn (array $row): string => trim((string) ($row['decision'] ?? '')), $studentDecisionRows ); $hasPending = in_array('', $allDecisions, true); $unique = array_values(array_unique(array_filter( $allDecisions, static fn (string $decision): bool => $decision !== '' ))); $eligible = ! $hasPending && $unique === ['Pass']; $displayDecisions = $eligible ? ['Pass'] : array_values(array_filter($unique, static fn (string $decision): bool => $decision !== 'Pass')); $yearScore = null; foreach ($studentDecisionRows as $row) { if (array_key_exists('year_score', $row) && $row['year_score'] !== null && $row['year_score'] !== '') { $yearScore = round((float) $row['year_score'], 2); break; } } return [ 'eligible' => $eligible, 'status' => ($hasPending || $displayDecisions === []) ? 'pending' : ($eligible ? 'pass' : 'decision'), 'display_decisions' => $displayDecisions, 'year_score' => $yearScore, ]; } /** * @param list $studentIds * @return list> */ private function fetchStudentsForIssuance(array $studentIds, ?int $classSectionId, string $schoolYear): array { $dashboard = $this->dashboard($schoolYear); $eligibleMap = []; foreach ($dashboard['grade_groups'] as $group) { foreach (($group['sections'] ?? []) as $section) { foreach (($section['students'] ?? []) as $student) { $sid = (int) ($student['student_id'] ?? 0); if ($sid > 0) { $eligibleMap[$sid] = $student; } } } } $filtered = []; foreach ($studentIds as $studentId) { $student = $eligibleMap[$studentId] ?? null; if (! is_array($student) || empty($student['eligible'])) { continue; } if ($classSectionId !== null && (int) ($student['class_section_id'] ?? 0) !== $classSectionId) { continue; } $filtered[] = [ 'id' => $studentId, 'firstname' => (string) ($student['firstname'] ?? ''), 'lastname' => (string) ($student['lastname'] ?? ''), 'grade' => (string) ($student['class_section_name'] ?? ''), ]; } return $filtered; } private function formatGrade(string $raw): string { $clean = trim($raw); $lower = strtolower($clean); if (preg_match('/^grade\s*(\d+)/i', $clean, $matches)) { return 'Grade '.(int) $matches[1]; } if ($lower === 'youth') { return 'Youth'; } if ($lower === 'kg' || $lower === 'kindergarten') { return 'Kindergarten'; } if (preg_match('/^(\d+)([- ][A-Za-z0-9]+)?$/', $clean, $matches)) { return 'Grade '.(int) $matches[1]; } return $clean; } private function normalizeDisplayCertDate(string $certDate): string { $clean = trim($certDate); if ($clean === '') { return date('m/d/Y'); } if (preg_match('#^\d{4}-\d{2}-\d{2}$#', $clean) === 1) { $timestamp = strtotime($clean); if ($timestamp !== false) { return date('m/d/Y', $timestamp); } } if (preg_match('#^\d{2}/\d{2}/\d{4}$#', $clean) === 1) { return $clean; } return date('m/d/Y'); } private function parseCertDate(string $certDate): ?string { if (preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $certDate, $matches) === 1) { return $matches[3].'-'.$matches[1].'-'.$matches[2]; } if (preg_match('#^\d{4}-\d{2}-\d{2}$#', $certDate) === 1) { return $certDate; } return null; } private function displayDateFromStorage(mixed $value): string { if ($value === null || trim((string) $value) === '') { return date('m/d/Y'); } $timestamp = strtotime((string) $value); return $timestamp !== false ? date('m/d/Y', $timestamp) : date('m/d/Y'); } private function formatCertificateNumber(string $schoolYear, int $sequence): string { return 'ARSS-'.$schoolYear.'-'.str_pad((string) $sequence, 4, '0', STR_PAD_LEFT); } private function generateVerificationToken(): string { if (! $this->hasVerificationTokenColumn) { return ''; } do { $token = bin2hex(random_bytes(16)); $exists = DB::table('certificate_records') ->where('verification_token', $token) ->exists(); } while ($exists); return $token; } /** * @param array $record */ private function ensureVerificationToken(array $record): string { if (! $this->hasVerificationTokenColumn) { return ''; } $token = trim((string) ($record['verification_token'] ?? '')); if ($token !== '') { return $token; } $recordId = (int) ($record['id'] ?? 0); if ($recordId <= 0) { return ''; } $token = $this->generateVerificationToken(); DB::table('certificate_records') ->where('id', $recordId) ->update([ 'verification_token' => $token, 'updated_at' => now(), ]); return $token; } /** * @return array{0: string, 1: string, 2: string} */ private function classGroupMeta(string $name): array { $lower = strtolower(trim($name)); if (preg_match('/grade\s*(\d+)/i', $name, $matches) === 1) { $grade = (int) $matches[1]; return [ 'Grade '.$grade, '1_'.str_pad((string) $grade, 3, '0', STR_PAD_LEFT), 'grade'.$grade, ]; } if (str_contains($lower, 'kg') || str_contains($lower, 'kindergarten')) { return ['KG', '0_kg', 'kg']; } if (str_contains($lower, 'arabic')) { return ['Arabic', '2_arabic', 'arabic']; } if (str_contains($lower, 'youth')) { return ['Youth', '5_youth', 'youth']; } return [ $name, '3_'.$lower, (string) preg_replace('/[^a-z0-9]+/', '-', $lower), ]; } /** * @return array{0: string, 1: string} */ private function splitStudentName(string $studentName): array { $parts = preg_split('/\s+/', trim($studentName), 2) ?: []; return [ $parts[0] ?? '', $parts[1] ?? '', ]; } }