Files
alrahma_sunday_school_api/app/Services/Certificates/CertificateAdminService.php
T
2026-06-09 00:03:03 -04:00

683 lines
23 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Certificates;
use App\Models\Configuration;
use App\Models\StudentDecision;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class CertificateAdminService
{
private bool $hasVerificationTokenColumn;
public function __construct()
{
$this->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<int> $studentIds
* @return array{students: list<array<string, mixed>>, 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<string, mixed>, 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<string, mixed>|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<int, array{name: string, total: int, pass: int, cert: int}> $statsPerClass
* @param array<int, list<array<string, mixed>>> $studentsByClass
* @return array<string, array<string, mixed>>
*/
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<array<string, mixed>> $studentDecisionRows
* @return array{eligible: bool, status: string, display_decisions: list<string>, 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<int> $studentIds
* @return list<array<string, mixed>>
*/
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<string, mixed> $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] ?? '',
];
}
}