Script ini membaca data ulang tahun dari XLSX, mencocokkan foto karyawan, membuat kartu PNG dari template, lalu mengirim seluruh kartu dalam satu email melalui SMTP. Implementasinya berjalan melalui PHP CLI 7.4 tanpa framework atau database, dengan Excel sebagai sumber data sekaligus penanda pengiriman tahunan.

Masalah yang diselesaikan

Input sistem terdiri dari sheet List Birthday, folder foto, template.png, font DM Sans Bold, logo, dan gambar footer. Baris Excel memuat nama, departemen, tanggal lahir, status aktif, foto pilihan, dan kolom Tanggal Kirim Email; outputnya adalah satu atau beberapa kartu PNG yang disematkan ke email HTML.

Nama file foto tidak selalu sama dengan nama di Excel. Sistem harus menolak foto yang hilang, rusak, atau ambigu agar tidak memasangkan wajah yang salah. Scheduler juga harus menggabungkan ulang tahun akhir pekan ke hari Senin, mengejar tanggal yang terlewat saat server mati, dan hanya memproses 29 Februari pada tahun kabisat.

Pengiriman dijalankan pukul 08.00, 09.00, dan 10.00. SMTP yang gagal dapat dicoba pada jadwal berikutnya, tetapi email yang sudah diterima SMTP tidak boleh dikirim ulang hanya karena proses gagal memperbarui Excel.

Keputusan teknis

  • PHP ^7.4 || ^8.0 menjadi runtime CLI agar perintah yang sama dapat berjalan di Windows dan cron Ubuntu.
  • phpoffice/phpspreadsheet membaca serta memperbarui XLSX.
  • ekstensi GD memuat foto, menghitung teks TrueType, dan merender kartu PNG.
  • PHPMailer mengirim HTML dan gambar inline melalui SMTP.
  • vlucas/phpdotenv memisahkan path, jadwal, penerima, dan kredensial dari source code.
  • File Excel menyimpan timestamp sukses; file .log mencatat tahap pengiriman dan error tanpa database tambahan.

Entry point birthday.php memuat modul berdasarkan tanggung jawab: pembacaan spreadsheet, scheduler, pencarian foto, renderer, mailer, jurnal, delivery service, dan command. Alur datanya adalah XLSX -> seleksi tanggal -> validasi foto -> render PNG -> SMTP -> update XLSX.

Excel dipilih sebagai state utama karena proses hanya berjalan terjadwal dan file production dipelihara oleh IT. Trade-off-nya adalah celah antara SMTP menerima pesan dan Excel berhasil disimpan; jurnal UNKNOWN serta Message-ID stabil dipakai untuk rekonsiliasi kondisi tersebut.

Implementasi

Membaca Excel dan membentuk kejadian

Fungsi employees() memakai sheet bernama List Birthday jika tersedia, lalu mengambil sheet pertama sebagai fallback. Tanggal lahir dapat berupa serial Excel atau teks, sedangkan identitas memakai ID karyawan dan jatuh kembali ke nama yang sudah dinormalisasi.

$reader = IOFactory::createReaderForFile($path);
$reader->setReadDataOnly(true);
$book = $reader->load($path);
$sheet = $book->getSheetByName('List Birthday') ?? $book->getSheet(0);

Fungsi occurrences() membentuk kejadian per tahun. checkdate() otomatis membuang 29 Februari pada tahun biasa, sedangkan nomor hari ISO menggeser Sabtu dan Minggu ke Senin.

if (!checkdate($p['month'], $p['day'], $y)) continue;
if ($respectSent && ($p['sent_at'] ?? null) !== null
    && (int) substr($p['sent_at'], 0, 4) >= $y) continue;

$weekday = (int) $birthday->format('N');
$due = $weekday >= 6
    ? $birthday->modify('+'.(8 - $weekday).' days')
    : $birthday;

Kolom Tanggal Kirim Email dibandingkan berdasarkan tahun kejadian. Timestamp tahun berjalan mencegah duplikasi, tetapi nilai tahun sebelumnya tidak memblokir ulang tahun berikutnya.

Memvalidasi foto dan merender kartu

photoIndex() menormalisasi nama file dan menghapus tambahan nomor dalam kurung. Jika beberapa file dengan nama cocok memiliki hash berbeda, resolvePhoto() menghasilkan AMBIGUOUS_PHOTO; kolom Foto dapat menunjuk path relatif yang sudah dipilih.

Renderer membatasi satu kartu menjadi lima orang. array_chunk() membuat kartu tambahan tanpa memecahnya menjadi email terpisah.

$cards = [];
foreach (array_chunk($people, 5) as $i => $group) {
    $file = 'card-'.($i + 1).'.png';
    renderCard($group, $dir.'/'.$file, $c);
    $cards[] = $file;
}

Fungsi positions() memakai pola [1], [2], [3], [2,2], dan [3,2]. Titik awal setiap baris dihitung dari lebar kanvas 472 piksel agar posisi tetap simetris.

$layouts = [1 => [1], 2 => [2], 3 => [3], 4 => [2,2], 5 => [3,2]];
$x = (472 - ($n * $w + ($n - 1) * $gap)) / 2;

Menjaga status pengiriman

deliver() mencatat SENDING sebelum memanggil transport. Setelah SMTP mengembalikan SENT, sistem mencatat SMTP_ACCEPTED, memperbarui Excel, lalu menulis EXCEL_UPDATED.

systemLog($c, 'INFO', 'SENDING', $context);
$result = $transport($delivery, $delivery['payload'], $delivery['dir'], $c);

if ($result['status'] === 'SENT') {
    systemLog($c, 'INFO', 'SMTP_ACCEPTED', $context);
    writeSentToExcel($c['EXCEL_PATH'], $people, $now, $c['LOGS_PATH']);
    systemLog($c, 'INFO', 'EXCEL_UPDATED', $context);
}

Jika transport terputus atau penyimpanan Excel gagal setelah SMTP menerima pesan, status menjadi UNKNOWN dan kejadian ditahan. Administrator memeriksa Message-ID, kemudian menjalankan --resolve=ID --as=sent|retry agar sistem tidak menebak hasil pengiriman.

Mode --preview menghasilkan kartu dan HTML tanpa SMTP atau perubahan Excel. Mode --test-email mengirim ke MAIL_TEST_TO dan memperbarui salinan XLSX, sedangkan verifikasi utama dijalankan dengan:

php birthday.php --check
php birthday.php --preview --from=2026-11-01 --date=2026-11-10
php tests/run.php

Test suite proyek menyelesaikan 46 pemeriksaan, termasuk akhir pekan, catch-up, tahun kabisat, layout, foto rusak, beberapa kartu, retry SMTP, backup Excel, status UNKNOWN, dan gambar inline.