fix financial and certificates

This commit is contained in:
root
2026-06-05 01:51:08 -04:00
parent d28d11e2e5
commit ad968eaff7
94 changed files with 9654 additions and 214 deletions
@@ -0,0 +1,682 @@
<?php
declare(strict_types=1);
namespace App\Services\Certificates;
use App\Models\CertificateRecord;
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] ?? '',
];
}
}
@@ -16,7 +16,7 @@ class CertificatePdfService
}
/**
* @param array<array{firstname: string, lastname: string, grade: string}> $students
* @param array<array<string, mixed>> $students
*/
public function generate(array $students, string $certDate): string
{
@@ -41,7 +41,22 @@ class CertificatePdfService
$pdf->AddPage();
$name = $student['firstname'] . ' ' . $student['lastname'];
$grade = $this->formatGrade((string) ($student['grade'] ?? ''));
$this->drawCertificate($pdf, $W, $H, $name, $grade, $certDate, $edwardianFont, $garamondBold, $ebGaramond);
$certNumber = (string) ($student['cert_number'] ?? '');
$verifyUrl = is_string($student['verify_url'] ?? null) ? $student['verify_url'] : null;
$this->drawCertificate(
$pdf,
$W,
$H,
$name,
$grade,
$certDate,
$certNumber,
$verifyUrl,
$edwardianFont,
$garamondBold,
$ebGaramond
);
}
return (string) $pdf->Output('', 'S');
@@ -54,28 +69,37 @@ class CertificatePdfService
string $name,
string $grade,
string $certDate,
string $certNumber,
?string $verifyUrl,
string $edwardianFont,
string $garamondBold,
string $ebGaramond
): void {
$pdf->Image($this->imgDir . 'title.png', 126, 0, 600);
$pdf->Image($this->imgDir . 'background.png', 280, 176, 291, 340);
$pdf->Image($this->imgDir . 'signature.png', 140, 399, 90, 90);
$pdf->Image($this->imgDir . 'signature.png', 140, 410, 90, 80);
$pdf->SetFont('times', 'B', 24);
$pdf->SetTextColor(0, 0, 0);
$pdf->SetXY(0, 151);
$pdf->Cell($W, 24, 'Presented to:', 0, 0, 'C');
if (! empty($verifyUrl)) {
$style = [
'border' => false,
'padding' => 0,
'fgcolor' => [0, 0, 0],
'bgcolor' => false,
];
$pdf->write2DBarcode($verifyUrl, 'QRCODE,L', 120, 162, 42, 42, $style, 'N');
}
$pdf->SetFont($edwardianFont, '', 38);
$pdf->SetDrawColor(0, 0, 0);
$pdf->setTextRenderingMode(0.4, true, false);
$pdf->SetXY(0, 219);
$pdf->Cell($W, 38, $name, 0, 0, 'C');
$pdf->setTextRenderingMode(0, true, false);
$nameX = ($W - $pdf->GetStringWidth($name)) / 2;
$this->drawGradientText($pdf, $edwardianFont, 38, $name, $nameX, 221.5);
$pdf->SetFont('times', '', 20);
$pdf->SetXY(0, 243);
$pdf->SetXY(0, 236);
$pdf->Cell(600, 20, '___________________________________', 0, 0, 'R');
$pdf->SetFont($ebGaramond, '', 20);
@@ -94,7 +118,7 @@ class CertificatePdfService
$pdf->SetXY(0, 375);
$pdf->Cell($W, 20, 'Al Rahma Sunday School', 0, 0, 'C');
$this->drawGradientText($pdf, $edwardianFont, 26, $certDate, 598, 453);
$this->drawGradientText($pdf, $edwardianFont, 26, $certDate, 586, 456);
$pdf->SetFont('times', '', 20);
$pdf->SetXY(0, 463);
@@ -106,6 +130,14 @@ class CertificatePdfService
$pdf->Cell(200, 20, '_________________', 0, 0, 'L');
$pdf->SetXY(106, 492);
$pdf->Cell(168, 20, 'Signature', 0, 0, 'C');
if ($certNumber !== '') {
$pdf->SetFont('helvetica', '', 8);
$pdf->SetTextColor(150, 150, 150);
$pdf->SetXY(70.86, $H - 39.68);
$pdf->Cell(200, 12, 'Certificate No. ' . $certNumber, 0, 0, 'L');
$pdf->SetTextColor(0, 0, 0);
}
}
private function drawGradientText(\TCPDF $pdf, string $fontName, float $fontSize, string $text, float $x, float $y): void