feat: certificate template management and generation (Fasa 7)

- CertificateService: Intervention Image v3 text overlay on template
- GenerateCertificateJob: queued generation with retry logic
- SendCertificateEmailJob: stub (implemented in Fasa 8)
- CertificateTemplateController: upload, config editor, preview, test generate
- Admin/CertificateController: list, generate-all, email-all
- Public/CertificateController: show with questionnaire gate, download
- DejaVuSans fonts bundled under resources/fonts
- Views: admin template/certificate management, public certificate download

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Saufi
2026-05-16 22:18:23 +08:00
parent 2f76f94283
commit 2ddc7e3caf
11 changed files with 993 additions and 3 deletions

View File

@@ -0,0 +1,126 @@
<?php
namespace App\Services;
use App\Models\Certificate;
use App\Models\Program;
use App\Models\Participant;
use App\Models\CertificateTemplate;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\ImageManager;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\Typography\FontFactory;
class CertificateService
{
private ImageManager $manager;
public function __construct()
{
$this->manager = new ImageManager(new Driver());
}
public function generate(Certificate $certificate): void
{
$certificate->update(['status' => 'generating']);
try {
$certificate->load(['participant', 'template', 'program']);
$template = $certificate->template;
if (! $template) {
throw new \RuntimeException('Template sijil tidak dijumpai.');
}
$templatePath = Storage::path($template->image_path);
if (! file_exists($templatePath)) {
throw new \RuntimeException('Fail template sijil tidak dijumpai di storage.');
}
$image = $this->manager->read($templatePath);
$config = $template->config_json ?? [];
$fields = $config['fields'] ?? [];
// Overlay name
if (isset($fields['name'])) {
$this->writeText($image, $certificate->participant->name, $fields['name']);
}
// Overlay certificate number if configured
if (isset($fields['certificate_no']) && $certificate->certificate_no) {
$this->writeText($image, $certificate->certificate_no, $fields['certificate_no']);
}
$outputDir = 'certificates/' . $certificate->program_id;
$outputFile = $outputDir . '/' . $certificate->uuid . '.jpg';
Storage::makeDirectory($outputDir);
$image->toJpeg(90)->save(Storage::path($outputFile));
$certificate->update([
'file_path' => $outputFile,
'status' => 'generated',
'generated_at' => now(),
'error_message'=> null,
]);
} catch (\Throwable $e) {
$certificate->update([
'status' => 'failed',
'error_message' => $e->getMessage(),
]);
}
}
public function generatePreview(CertificateTemplate $template, string $sampleName, string $sampleNo = ''): string
{
$templatePath = Storage::path($template->image_path);
$image = $this->manager->read($templatePath);
$config = $template->config_json ?? [];
$fields = $config['fields'] ?? [];
if (isset($fields['name'])) {
$this->writeText($image, $sampleName, $fields['name']);
}
if (isset($fields['certificate_no']) && $sampleNo) {
$this->writeText($image, $sampleNo, $fields['certificate_no']);
}
return $image->toJpeg(85)->toString();
}
private function writeText(\Intervention\Image\Image $image, string $text, array $cfg): void
{
$fontFile = $this->resolveFontPath($cfg['font_file'] ?? 'DejaVuSans-Bold.ttf');
$fontSize = (int) ($cfg['font_size'] ?? 48);
$fontColor = (string)($cfg['font_color'] ?? '#000000');
$align = (string)($cfg['align'] ?? 'center');
$valign = (string)($cfg['valign'] ?? 'top');
$x = (int) ($cfg['x'] ?? 0);
$y = (int) ($cfg['y'] ?? 0);
$image->text($text, $x, $y, function (FontFactory $font) use ($fontFile, $fontSize, $fontColor, $align, $valign) {
$font->filename($fontFile);
$font->size($fontSize);
$font->color($fontColor);
$font->align($align);
$font->valign($valign);
});
}
private function resolveFontPath(string $filename): string
{
$custom = resource_path('fonts/' . $filename);
if (file_exists($custom)) {
return $custom;
}
return resource_path('fonts/DejaVuSans-Bold.ttf');
}
public function buildCertificateNo(Program $program, Participant $participant, int $sequence): string
{
$year = now()->format('Y');
$prefix = strtoupper(substr(preg_replace('/[^A-Za-z]/', '', $program->title), 0, 4));
return sprintf('%s/%s/%04d', $prefix, $year, $sequence);
}
}