fix reportcard and certificate

This commit is contained in:
root
2026-05-27 14:11:08 -04:00
parent c294d7bed7
commit 3737b3522d
6 changed files with 378 additions and 54 deletions
+1
View File
@@ -9,6 +9,7 @@ use CodeIgniter\Format\XMLFormatter;
class Format extends BaseConfig
{
public int $jsonEncodeDepth = 512;
/**
* --------------------------------------------------------------------------
* Available Response Formats
+37 -8
View File
@@ -177,7 +177,10 @@ class CertificateController extends BaseController
->table('certificate_records cr')
->select('cr.*, u.firstname AS admin_firstname, u.lastname AS admin_lastname')
->join('users u', 'u.id = cr.issued_by', 'left')
->where('cr.certificate_number', strtoupper($certNumber))
->groupStart()
->where('cr.verification_token', $certNumber)
->orWhere('cr.certificate_number', strtoupper($certNumber))
->groupEnd()
->get()->getRowArray();
return view('certificates/verify', ['record' => $record ?: null]);
@@ -210,6 +213,7 @@ class CertificateController extends BaseController
'lastname' => '',
'grade' => (string)($record['grade'] ?? ''),
'cert_number' => (string)($record['certificate_number'] ?? ''),
'verify_token' => $this->ensureVerificationTokenForRecord($record),
];
// student_name is stored as "Firstname Lastname" — split for the PDF builder
@@ -317,24 +321,28 @@ class CertificateController extends BaseController
// Load any existing certificates for these students this school year
$db = \Config\Database::connect();
$existingCerts = $db->table('certificate_records')
->select('student_id, certificate_number')
->select('id, student_id, certificate_number, verification_token')
->whereIn('student_id', array_column($students, 'id'))
->where('school_year', $schoolYear)
->get()->getResultArray();
$existingCertMap = [];
foreach ($existingCerts as $ec) {
$existingCertMap[(int)$ec['student_id']] = $ec['certificate_number'];
$existingCertMap[(int)$ec['student_id']] = $ec;
}
foreach ($students as &$student) {
$sid = (int)$student['id'];
if (isset($existingCertMap[$sid])) {
// Reuse existing certificate number — do not create a new record
$student['cert_number'] = $existingCertMap[$sid];
$existing = $existingCertMap[$sid];
$student['cert_number'] = (string)($existing['certificate_number'] ?? '');
$student['verify_token'] = $this->ensureVerificationTokenForRecord($existing);
} else {
$certNumber = $this->certRecordModel->nextNumber($schoolYear);
$verifyToken = $this->certRecordModel->generateVerificationToken();
$this->certRecordModel->insert([
'certificate_number' => $certNumber,
'verification_token' => $verifyToken,
'student_id' => $sid,
'student_name' => $student['firstname'] . ' ' . $student['lastname'],
'grade' => $this->formatGrade($student['grade'] ?? ''),
@@ -345,6 +353,7 @@ class CertificateController extends BaseController
'issued_at' => $issuedAt,
]);
$student['cert_number'] = $certNumber;
$student['verify_token'] = $verifyToken;
}
}
unset($student);
@@ -396,6 +405,24 @@ class CertificateController extends BaseController
return $clean;
}
private function ensureVerificationTokenForRecord(array $record): string
{
$token = trim((string)($record['verification_token'] ?? ''));
if ($token !== '') {
return $token;
}
$recordId = (int)($record['id'] ?? 0);
if ($recordId <= 0) {
return '';
}
$token = $this->certRecordModel->generateVerificationToken();
$this->certRecordModel->update($recordId, ['verification_token' => $token]);
return $token;
}
// ─── PDF rendering ─────────────────────────────────────────────────────────
private function buildPdf(array $students, string $certDate): string
@@ -426,9 +453,11 @@ class CertificateController extends BaseController
$name = $student['firstname'] . ' ' . $student['lastname'];
$grade = $this->formatGrade($student['grade'] ?? '');
$certNumber = $student['cert_number'] ?? '';
$verifyToken = $student['verify_token'] ?? '';
// Build verification URL for an inline TCPDF QR code.
$verifyUrl = site_url('verify/' . rawurlencode($certNumber));
$verifyUrl = $verifyToken !== ''
? site_url('verify/' . rawurlencode($verifyToken))
: null;
$this->drawCertificate(
$pdf, $W, $H,
@@ -487,11 +516,11 @@ class CertificateController extends BaseController
// ── Student name — center based on actual string width
$pdf->SetFont($edwardianFont, '', 38);
$nameX = ($W - $pdf->GetStringWidth($name)) / 2;
$this->drawGradientText($pdf, $edwardianFont, 38, $name, $nameX, 229.5);
$this->drawGradientText($pdf, $edwardianFont, 38, $name, $nameX, 221.5);
// ── Line under name
$pdf->SetFont('times', '', 20);
$pdf->SetXY(0, 243);
$pdf->SetXY(0, 236);
$pdf->Cell(600, 20, '___________________________________', 0, 0, 'R');
// ── Description
+219 -46
View File
@@ -845,6 +845,7 @@ class ReportCardsController extends PrintablesBaseController
$studentName = trim(($data['student']['firstname'] ?? '') . ' ' . ($data['student']['lastname'] ?? ''));
$gradeLabel = (string)($data['grade'] ?? ($data['class_section_name'] ?? 'N/A'));
$today = (string)($data['report_date_display'] ?? date('m-d-Y'));
$termRank = trim((string)($data['term_rank_display'] ?? ''));
$firstSemScore = $data['first_semester_score'] ?? null;
$secondSemScore = $data['second_semester_score'] ?? ($data['total_score'] ?? null);
$finalAverage = $data['final_average'] ?? null;
@@ -980,54 +981,91 @@ class ReportCardsController extends PrintablesBaseController
$gradeY = $pdf->GetY();
$gradePad = max(0, 2 - (4 * 0.3528)); // reduce indentation by 4pt
$drawGradeCell = static function (\FPDF $pdf, float $x, float $y, float $w, float $h, string $label, string $value, float $pad) {
$pdf->Rect($x, $y, $w, $h);
$pdf->SetXY($x + $pad, $y + 3);
$pdf->SetFont('Helvetica', 'B', 11);
$pdf->Write(5, $label . ' ');
$labelWidth = $pdf->GetStringWidth($label . ' ');
$pdf->SetFont('Helvetica', '', 12);
$pdf->SetXY($x + 2 + $labelWidth, $y + 3);
$pdf->Write(5, $value);
};
$drawGradeCell = static function (
\FPDF $pdf,
float $x,
float $y,
float $w,
float $h,
string $label,
string $value,
float $pad
) {
$pdf->Rect($x, $y, $w, $h);
$pdf->SetXY($x + $pad, $y + 3);
$pdf->SetFont('Helvetica', 'B', 11);
$pdf->Write(5, $label . ' ');
$labelWidth = $pdf->GetStringWidth($label . ' ');
$pdf->SetFont('Helvetica', '', 12);
$pdf->SetXY($x + 2 + $labelWidth, $y + 3);
$pdf->Write(5, $value);
};
$drawRankCell = static function (
\FPDF $pdf,
float $x,
float $y,
float $w,
float $h,
string $rankValue,
float $pad
) {
$rankValue = trim($rankValue) !== '' ? trim($rankValue) : 'N/A';
if ($semNum === 2) {
$firstLabel = ' 1st Semester Grade:';
$firstValue = $numFmt($firstSemScore) . '/100';
$secondLabel = ' 2nd Semester Grade:';
$secondValue = $numFmt($effectiveSecond) . '/100';
$drawGradeCell($pdf, $gradeX, $gradeY, $gradeCellWidth, $gradeCellHeight, $firstLabel, $firstValue, $gradePad);
$drawGradeCell($pdf, $gradeX + $gradeCellWidth, $gradeY, $gradeCellWidth, $gradeCellHeight, $secondLabel, $secondValue, $gradePad);
} else {
$gradeLabel = ' 1st Semester Grade:';
$gradeValue = $numFmt($effectiveSecond) . '/100';
$drawGradeCell($pdf, $gradeX, $gradeY, $gradeCellWidth, $gradeCellHeight, $gradeLabel, $gradeValue, $gradePad);
}
$pdf->SetY($gradeY + $gradeCellHeight);
$pdf->Rect($x, $y, $w, $h);
$pdf->SetXY($x + $pad, $y + 3);
$pdf->SetFont('Helvetica', 'B', 11);
$pdf->Write(5, ' Ranking: ');
$finalScoreRowHeight = 12;
$finalScoreEndY = null;
// Show Final Score only for second semester / Spring
if ($semNum === 2) {
$finalLabel = 'Final Score****:';
$finalValue = (is_string($finalScoreVal) ? $finalScoreVal : $numFmt($finalScoreVal)) . '/100';
$finalCellWidth = 65; // match Total Semester Days column width
$finalCellHeight = $finalScoreRowHeight;
$finalX = $pdf->GetX();
$finalY = $pdf->GetY();
$pdf->Rect($finalX, $finalY, $finalCellWidth, $finalCellHeight);
$finalPad = max(0, 2 - (4 * 0.3528)); // reduce indentation by 4pt
$pdf->SetXY($finalX + $finalPad, $finalY + 3);
$pdf->SetFont('Helvetica', 'B', 11);
$pdf->Write(5, ' ' . $finalLabel . ' ');
$finalLabelWidth = $pdf->GetStringWidth(' ' . $finalLabel . ' ');
$pdf->SetFont('Helvetica', '', 12);
$pdf->SetXY($finalX + 2 + $finalLabelWidth, $finalY + 3);
$pdf->Write(5, $finalValue);
$pdf->Ln($finalCellHeight);
$finalScoreEndY = $finalY + $finalCellHeight;
}
$scoresEndY = $pdf->GetY();
$labelWidth = $pdf->GetStringWidth(' Ranking: ');
$pdf->SetFont('Helvetica', '', 12);
$pdf->SetXY($x + 2 + $labelWidth, $y + 3);
$pdf->Write(5, $rankValue);
};
if ($semNum === 2) {
$firstLabel = ' 1st Semester Grade:';
$firstValue = $numFmt($firstSemScore) . '/100';
$secondLabel = ' 2nd Semester Grade:';
$secondValue = $numFmt($effectiveSecond) . '/100';
$drawGradeCell($pdf, $gradeX, $gradeY, $gradeCellWidth, $gradeCellHeight, $firstLabel, $firstValue, $gradePad);
$drawGradeCell($pdf, $gradeX + $gradeCellWidth, $gradeY, $gradeCellWidth, $gradeCellHeight, $secondLabel, $secondValue, $gradePad);
} else {
$gradeLabel = ' 1st Semester Grade:';
$gradeValue = $numFmt($effectiveSecond) . '/100';
$drawGradeCell($pdf, $gradeX, $gradeY, $gradeCellWidth, $gradeCellHeight, $gradeLabel, $gradeValue, $gradePad);
// Fall: Ranking goes to the right of 1st Semester Grade.
$drawRankCell($pdf, $gradeX + $gradeCellWidth, $gradeY, $gradeCellWidth, $gradeCellHeight, $termRank, $gradePad);
}
$pdf->SetY($gradeY + $gradeCellHeight);
$finalScoreRowHeight = 12;
$finalScoreEndY = null;
// Show Final Score only for second semester / Spring.
if ($semNum === 2) {
$finalLabel = 'Final Score****:';
$finalValue = (is_string($finalScoreVal) ? $finalScoreVal : $numFmt($finalScoreVal)) . '/100';
$finalCellWidth = 65;
$finalCellHeight = $finalScoreRowHeight;
$finalX = $pdf->GetX();
$finalY = $pdf->GetY();
$finalPad = max(0, 2 - (4 * 0.3528));
$drawGradeCell($pdf, $finalX, $finalY, $finalCellWidth, $finalCellHeight, ' ' . $finalLabel, $finalValue, $finalPad);
// Spring: Ranking goes to the right of Final Score.
$drawRankCell($pdf, $finalX + $finalCellWidth, $finalY, $finalCellWidth, $finalCellHeight, $termRank, $finalPad);
$pdf->SetY($finalY + $finalCellHeight);
$finalScoreEndY = $finalY + $finalCellHeight;
}
$scoresEndY = $pdf->GetY();
// Legend anchored near bottom with a 3mm buffer (dynamic placement)
$examLegendLabel = ($semNum === 1) ? 'Midterm Exam' : 'Final Exam';
@@ -1546,6 +1584,15 @@ class ReportCardsController extends PrintablesBaseController
$firstSemesterScore = $secondSemesterScore;
}
$termRanking = $this->calculateTermRanking(
$studentId,
$sectionCode,
$sectionId,
$refYear,
$refSemester !== '' ? $refSemester : (string)($score['semester'] ?? ''),
$secondSemesterScore
);
// Total semester days from attendance records (max total_attendance within same term)
$totalSemesterDays = null;
@@ -1672,12 +1719,138 @@ class ReportCardsController extends PrintablesBaseController
'second_semester_score' => $secondSemesterScore,
'first_semester_score' => $firstSemesterScore,
'final_average' => $finalAverage,
'term_rank' => $termRanking,
'term_rank_display' => $termRanking['display'] ?? null,
'comments' => $commentMap,
'total_score' => $secondSemesterScore,
'total_attendance_days' => $totalSemesterDays,
];
}
private function calculateTermRanking(
int $studentId,
int $sectionCode,
?int $sectionId,
string $schoolYear,
?string $semester,
?float $studentScore
): ?array {
if ($studentId <= 0 || $schoolYear === '' || $studentScore === null) {
return null;
}
$sectionIds = array_values(array_unique(array_filter([
$sectionCode > 0 ? $sectionCode : null,
$sectionId && $sectionId > 0 ? $sectionId : null,
])));
if (empty($sectionIds)) {
return null;
}
$builder = $this->db->table('semester_scores ss')
->select('ss.student_id, ss.semester_score, ss.updated_at, ss.id, s.firstname, s.lastname')
->join('students s', 's.id = ss.student_id', 'inner')
->where('s.is_active', 1)
->where('ss.school_year', $schoolYear)
->whereIn('ss.class_section_id', $sectionIds)
->orderBy('ss.updated_at', 'DESC')
->orderBy('ss.id', 'DESC');
if (trim((string)$semester) !== '') {
$this->applySemesterFilter($builder, (string)$semester, 'ss.semester');
}
$rows = $builder->get()->getResultArray();
if (empty($rows)) {
return null;
}
$scoresByStudent = [];
foreach ($rows as $row) {
$sid = (int)($row['student_id'] ?? 0);
if ($sid <= 0 || isset($scoresByStudent[$sid])) {
continue;
}
$scoreVal = $row['semester_score'] ?? null;
if (!is_numeric($scoreVal)) {
continue;
}
$scoresByStudent[$sid] = [
'student_id' => $sid,
'score' => round((float)$scoreVal, 4),
'firstname' => trim((string)($row['firstname'] ?? '')),
'lastname' => trim((string)($row['lastname'] ?? '')),
];
}
if (empty($scoresByStudent) || !isset($scoresByStudent[$studentId])) {
return null;
}
$rankable = array_values($scoresByStudent);
usort($rankable, static function (array $a, array $b): int {
$scoreCmp = $b['score'] <=> $a['score'];
if ($scoreCmp !== 0) {
return $scoreCmp;
}
$lastCmp = strcasecmp($a['lastname'], $b['lastname']);
if ($lastCmp !== 0) {
return $lastCmp;
}
$firstCmp = strcasecmp($a['firstname'], $b['firstname']);
if ($firstCmp !== 0) {
return $firstCmp;
}
return $a['student_id'] <=> $b['student_id'];
});
$position = null;
$previousScore = null;
foreach ($rankable as $index => $row) {
if ($previousScore === null || abs($row['score'] - $previousScore) > 0.0001) {
$position = $index + 1;
$previousScore = $row['score'];
}
if ((int)$row['student_id'] === $studentId) {
$total = count($rankable);
return [
'position' => $position,
'total' => $total,
'display' => $this->formatOrdinal($position) . ' of ' . $total,
];
}
}
return null;
}
private function formatOrdinal(?int $value): string
{
$n = (int)$value;
if ($n <= 0) {
return '';
}
$mod100 = $n % 100;
if ($mod100 >= 11 && $mod100 <= 13) {
return $n . 'th';
}
return match ($n % 10) {
1 => $n . 'st',
2 => $n . 'nd',
3 => $n . 'rd',
default => $n . 'th',
};
}
/**
* Resolve teacher & TA names for a class section (by PK or code),
* relaxing semester/year if needed. Returns ['teacher_name' => string, 'ta_names' => string[]].
@@ -0,0 +1,73 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddVerificationTokenToCertificateRecords extends Migration
{
private const INDEX_NAME = 'uniq_certificate_verification_token';
public function up()
{
if (!$this->db->tableExists('certificate_records')) {
return;
}
if (!$this->db->fieldExists('verification_token', 'certificate_records')) {
$this->forge->addColumn('certificate_records', [
'verification_token' => [
'type' => 'VARCHAR',
'constraint' => 64,
'null' => true,
'after' => 'certificate_number',
],
]);
}
$rows = $this->db->table('certificate_records')
->select('id, verification_token')
->get()
->getResultArray();
foreach ($rows as $row) {
if (trim((string)($row['verification_token'] ?? '')) !== '') {
continue;
}
$this->db->table('certificate_records')
->where('id', (int)$row['id'])
->update(['verification_token' => $this->generateToken()]);
}
$this->forge->addUniqueKey('verification_token', self::INDEX_NAME);
$this->forge->processIndexes('certificate_records');
}
public function down()
{
if (!$this->db->tableExists('certificate_records')) {
return;
}
if ($this->db->fieldExists('verification_token', 'certificate_records')) {
$this->forge->dropKey('certificate_records', self::INDEX_NAME);
$this->forge->dropColumn('certificate_records', 'verification_token');
}
}
private function generateToken(): string
{
do {
$token = bin2hex(random_bytes(16));
$exists = $this->db->table('certificate_records')
->select('id')
->where('verification_token', $token)
->limit(1)
->get()
->getRowArray();
} while ($exists);
return $token;
}
}
+18
View File
@@ -12,6 +12,7 @@ class CertificateRecordModel extends Model
protected $allowedFields = [
'certificate_number',
'verification_token',
'student_id',
'student_name',
'grade',
@@ -44,6 +45,23 @@ class CertificateRecordModel extends Model
return 'ARSS-' . $schoolYear . '-' . $seq;
}
public function generateVerificationToken(): string
{
$db = \Config\Database::connect();
do {
$token = bin2hex(random_bytes(16));
$exists = $db->table($this->table)
->select('id')
->where('verification_token', $token)
->limit(1)
->get()
->getRowArray();
} while ($exists);
return $token;
}
/** Returns paginated records with the issuing admin's name joined. */
public function getAuditLog(?string $schoolYear = null, int $perPage = 50): array
{
+30
View File
@@ -0,0 +1,30 @@
services:
mysql:
image: mysql:8.4
container_name: mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: school
MYSQL_USER: root
MYSQL_PASSWORD: password
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
phpmyadmin:
image: phpmyadmin/phpmyadmin:latest
platform: linux/amd64
container_name: phpmyadmin
restart: unless-stopped
environment:
PMA_HOST: mysql
PMA_PORT: 3306
ports:
- "8081:80"
depends_on:
- mysql
volumes:
mysql_data: