Lima bagian sebelumnya membangun mesin yang menulis: order ditempatkan, stok bergerak, uang tercatat. Pada bagian ini kita membaca kembali semua itu lewat dua class yang sama-sama tidak mengubah satu baris data pun — ReportService menjawab pertanyaan bisnis (berapa omzet bulan ini, produk apa yang paling laku, siapa pelanggan paling setia), sedangkan AuditLogger menjawab pertanyaan kepatuhan (siapa mengubah apa, kapan, dan bagaimana keadaan sebelum serta sesudahnya). Keduanya berbagi satu keyakinan: laporan dan audit tidak boleh pernah menjadi jalur tulis. Kita juga melatih dua keahlian SQL yang selalu hadir di sistem nyata — JOIN tiga tabel dengan agregasi, dan penataan snapshot JSON yang deterministik — plus satu pelajaran desain: membuat tabel yang menolak diubah dengan trigger database.

Laporan Penjualan Hanya Membaca #

ReportService membuka koneksi, menjalankan SELECT, lalu memformat hasilnya — tidak ada beginTransaction(), tidak ada UPDATE. Sifat read-only ini bukan kebetalan desain: laporan dipanggil dari dashboard yang bisa dibuka puluhan kali per menit, dan satu-satunya cara memastikan pemanggilan laporan tidak pernah merusak data adalah dengan tidak menyediakan jalur tulis sama sekali.

Pertanyaan pertama yang harus dijawab sebelum menulis query-nya: order seperti apa yang dihitung sebagai penjualan? Jawaban kita: hanya order yang uangnya benar-benar masuk:

Status orderMasuk laporan?Alasan
pendingtidaktagihan baru dibuat, uang belum masuk
dibayaryapembayaran settlement
dikirimyalanjutan dari dibayar
selesaiyalanjutan dari dikirim
bataltidakorder batal, uang tidak masuk

Daftar ini kita simpan sebagai konstanta STATUS_TERJUAL sehingga tiga method laporan membaca satu sumber kebenaran yang sama. Ketika kelak ada status baru (misalnya refund), perubahan definisi cukup dilakukan di satu tempat.

Class ReportService #

Berikut isi lengkap src/ReportService.php:

php
<?php

declare(strict_types=1);

/**
 * Laporan penjualan dari data order yang sudah ada.
 *
 * ReportService murni membaca: tidak satu pun method mengubah baris
 * data, sehingga aman dipanggil kapan pun tanpa transaksi. Seluruh
 * nilai uang keluar dalam satuan sen (INTEGER), mengikuti konvensi
 * proyek; rata-rata dihitung dengan intdiv() sehingga tetap eksak
 * dalam integer (pembulatan ke bawah).
 */
final class ReportService
{
    /**
     * Status order yang dihitung sebagai penjualan: uang sudah benar-benar
     * masuk. Order pending (belum dibayar) dan batal (uang tidak masuk)
     * tidak pernah masuk laporan penjualan.
     */
    public const STATUS_TERJUAL = ['dibayar', 'dikirim', 'selesai'];

    public function __construct(private PDO $pdo)
    {
        $this->pdo->exec('PRAGMA foreign_keys = ON');
    }

    /**
     * Ringkasan penjualan satu periode waktu.
     *
     * Rentang dibandingkan terhadap orders.created_at secara inklusif di
     * kedua ujung (BETWEEN). Waktu disimpan sebagai string UTC format
     * 'Y-m-d H:i:s' — sama dengan CURRENT_TIMESTAMP SQLite — sehingga
     * perbandingan string langsung benar.
     *
     * @param DateTimeImmutable $dari   awal periode (inklusif)
     * @param DateTimeImmutable $sampai akhir periode (inklusif)
     *
     * @return array{omzet: int, jumlahOrder: int, rataRata: int}
     *         seluruh nilai uang dalam sen; rataRata adalah intdiv(omzet,
     *         jumlahOrder) dan bernilai 0 bila tidak ada penjualan.
     */
    public function penjualanPerPeriode(DateTimeImmutable $dari, DateTimeImmutable $sampai): array
    {
        $stmt = $this->pdo->prepare(
            'SELECT COUNT(DISTINCT o.id) AS jumlah_order,
                    COALESCE(SUM(oi.qty * oi.harga), 0) AS omzet
               FROM orders o
               JOIN order_items oi ON oi.order_id = o.id
              WHERE o.status IN (' . $this->klausulStatusTerjual() . ')
                AND o.created_at BETWEEN ? AND ?'
        );
        $stmt->execute([$dari->format('Y-m-d H:i:s'), $sampai->format('Y-m-d H:i:s')]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);

        $jumlahOrder = (int) $row['jumlah_order'];
        $omzet = (int) $row['omzet'];

        return [
            'omzet'       => $omzet,
            'jumlahOrder' => $jumlahOrder,
            'rataRata'    => $jumlahOrder === 0 ? 0 : intdiv($omzet, $jumlahOrder),
        ];
    }

    /**
     * N produk terlaris berdasarkan total qty yang terjual (hanya order
     * berstatus terjual). Urutan: qty turun, lalu omzet turun, lalu id
     * naik — dua pengurai (tiebreaker) terakhir menjamin urutan yang
     * sama untuk data yang sama.
     *
     * @param int $n jumlah produk yang diminta
     *
     * @return list<array{id: int, sku: string, nama: string, qtyTerjual: int, omzet: int}>
     */
    public function produkTerlaris(int $n = 5): array
    {
        $stmt = $this->pdo->prepare(
            'SELECT p.id, p.sku, p.nama,
                    SUM(oi.qty) AS qty_terjual,
                    SUM(oi.qty * oi.harga) AS omzet
               FROM order_items oi
               JOIN orders o ON o.id = oi.order_id
               JOIN products p ON p.id = oi.product_id
              WHERE o.status IN (' . $this->klausulStatusTerjual() . ')
              GROUP BY p.id, p.sku, p.nama
              ORDER BY qty_terjual DESC, omzet DESC, p.id ASC
              LIMIT ?'
        );
        $stmt->execute([$n]);

        return array_map(
            static fn(array $r): array => [
                'id'         => (int) $r['id'],
                'sku'        => (string) $r['sku'],
                'nama'       => (string) $r['nama'],
                'qtyTerjual' => (int) $r['qty_terjual'],
                'omzet'      => (int) $r['omzet'],
            ],
            $stmt->fetchAll(PDO::FETCH_ASSOC)
        );
    }

    /**
     * Pelanggan paling aktif: diurutkan berdasarkan jumlah order terjual,
     * lalu total belanja, lalu id — semuanya deterministik.
     *
     * @param int $n jumlah pelanggan yang diminta
     *
     * @return list<array{id: int, nama: string, email: string, jumlahOrder: int, totalBelanja: int}>
     */
    public function pelangganAktif(int $n = 5): array
    {
        $stmt = $this->pdo->prepare(
            'SELECT c.id, c.nama, c.email,
                    COUNT(DISTINCT o.id) AS jumlah_order,
                    COALESCE(SUM(oi.qty * oi.harga), 0) AS total_belanja
               FROM customers c
               JOIN orders o ON o.customer_id = c.id
               JOIN order_items oi ON oi.order_id = o.id
              WHERE o.status IN (' . $this->klausulStatusTerjual() . ')
              GROUP BY c.id, c.nama, c.email
              ORDER BY jumlah_order DESC, total_belanja DESC, c.id ASC
              LIMIT ?'
        );
        $stmt->execute([$n]);

        return array_map(
            static fn(array $r): array => [
                'id'           => (int) $r['id'],
                'nama'         => (string) $r['nama'],
                'email'        => (string) $r['email'],
                'jumlahOrder'  => (int) $r['jumlah_order'],
                'totalBelanja' => (int) $r['total_belanja'],
            ],
            $stmt->fetchAll(PDO::FETCH_ASSOC)
        );
    }

    /**
     * Daftar status terjual sebagai fragmen IN(...) — konstanta internal,
     * bukan masukan pengguna, jadi perangkai string di sini aman.
     */
    private function klausulStatusTerjual(): string
    {
        return "'" . implode("','", self::STATUS_TERJUAL) . "'";
    }
}

Beberapa detail SQL di atas layak dibedah:

  1. COUNT(DISTINCT o.id), bukan COUNT(*) — JOIN ke order_items menggandakan baris order (satu baris per item), sehingga COUNT(*) akan menghitung order tiga item sebagai tiga. DISTINCT mengembalikannya menjadi jumlah order yang sebenarnya, sementara SUM(oi.qty * oi.harga) tetap benar karena setiap baris item memang harus dijumlahkan semuanya.
  2. COALESCE(SUM(...), 0) — saat periode kosong, SUM() mengembalikan NULL; COALESCE mengubahnya menjadi 0 agar type jelas sejak dari database, bukan diperbaiki di kemudian hari oleh kode yang lupa.
  3. Tiebreaker pada ORDER BY — qty_terjual DESC, omzet DESC, p.id ASC memastikan dua produk dengan qty sama selalu urut sama. Tanpa pengurai terakhir, SQLite bebas mengembalikan urutan mana pun — laporan yang berubah-ubah antar eksekusi adalah bug yang sulit dilacak.
  4. intdiv() untuk rata-rata — omzet dan jumlah order sama-sama integer sen; pembagian biasa (/) akan menghasilkan float dan mengundang kembali masalah presisi yang kita kubur pada Bagian 1. intdiv() membulatkan ke bawah dan tetap eksak.

Walkthrough Laporan Penjualan #

Laporan butuh data yang tersebar di waktu, sementara placeOrder() selalu menulis created_at dengan waktu sekarang. Untuk walkthrough ini kita memakai teknik penyemaian data (seeding): order tetap dibuat lewat placeOrder() sungguhan — lengkap dengan reservasi stok dan gerakannya — lalu created_at ditimpa dengan tanggal beku lewat UPDATE agar sejarah satu bulan penuh terjadi dalam satu skrip dan outputnya tidak pernah berubah:

php
<?php
require __DIR__ . '/../bagian-2-order-dan-order-items/src/OrderRepository.php';
require __DIR__ . '/../bagian-3-order-processing/src/OrderService.php';
require __DIR__ . '/src/ReportService.php';

function rupiah(int $sen): string
{
    return sprintf('Rp%s,%02d', number_format(intdiv($sen, 100), 0, ',', '.'), $sen % 100);
}

function backdate(PDO $pdo, int $orderId, string $waktu): void
{
    $pdo->prepare('UPDATE orders SET created_at = ? WHERE id = ?')
        ->execute([$waktu, $orderId]);
}

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-model-pelanggan-dan-produk/schema.sql'));

$service = new OrderService($pdo, new OrderRepository($pdo));
$report = new ReportService($pdo);

// [customerId, items, created_at beku, status tujuan berurutan]
$dataset = [
    [1, [[1, 1], [2, 1]], '2026-01-05 09:30:00', ['dibayar']],
    [2, [[2, 3]],         '2026-01-08 14:10:00', ['dibayar', 'dikirim', 'selesai']],
    [3, [[3, 2]],         '2026-01-12 11:00:00', ['dibayar', 'dikirim']],
    [4, [[4, 1]],         '2026-01-15 16:45:00', []],
    [1, [[5, 1], [6, 2]], '2026-01-20 10:20:00', ['batal']],
    [2, [[1, 1]],         '2026-01-25 13:05:00', ['dibayar']],
    [5, [[2, 2], [6, 1]], '2026-02-02 09:00:00', ['dibayar']],
];

foreach ($dataset as [$customerId, $items, $waktu, $statuses]) {
    $orderId = $service->placeOrder($customerId, $items);
    backdate($pdo, $orderId, $waktu);
    foreach ($statuses as $status) {
        $service->ubahStatus($orderId, $status);
    }
}
printf("Dataset siap: %d order, %d gerakan stok\n\n",
    (int) $pdo->query('SELECT COUNT(*) FROM orders')->fetchColumn(),
    (int) $pdo->query('SELECT COUNT(*) FROM stock_movements')->fetchColumn()
);

$r = $report->penjualanPerPeriode(
    new DateTimeImmutable('2026-01-01 00:00:00', new DateTimeZone('UTC')),
    new DateTimeImmutable('2026-01-31 23:59:59', new DateTimeZone('UTC'))
);
printf("Januari : omzet %s, %d order, rata-rata %s\n",
    rupiah($r['omzet']), $r['jumlahOrder'], rupiah($r['rataRata']));

$r = $report->penjualanPerPeriode(
    new DateTimeImmutable('2026-01-01 00:00:00', new DateTimeZone('UTC')),
    new DateTimeImmutable('2026-01-14 23:59:59', new DateTimeZone('UTC'))
);
printf("1-14 Jan: omzet %s, %d order, rata-rata %s\n",
    rupiah($r['omzet']), $r['jumlahOrder'], rupiah($r['rataRata']));

$r = $report->penjualanPerPeriode(
    new DateTimeImmutable('2026-02-01 00:00:00', new DateTimeZone('UTC')),
    new DateTimeImmutable('2026-02-28 23:59:59', new DateTimeZone('UTC'))
);
printf("Februari: omzet %s, %d order, rata-rata %s\n\n",
    rupiah($r['omzet']), $r['jumlahOrder'], rupiah($r['rataRata']));

echo "Produk terlaris:\n";
foreach ($report->produkTerlaris(3) as $i => $p) {
    printf("  %d. %-24s (%s) qty %d — omzet %s\n",
        $i + 1, $p['nama'], $p['sku'], $p['qtyTerjual'], rupiah($p['omzet']));
}

echo "\nPelanggan aktif:\n";
foreach ($report->pelangganAktif(3) as $i => $c) {
    printf("  %d. %-14s %d order — total %s\n",
        $i + 1, $c['nama'], $c['jumlahOrder'], rupiah($c['totalBelanja']));
}

Output yang Dihasilkan:

text
Dataset siap: 7 order, 16 gerakan stok

Januari : omzet Rp3.499.000,00, 4 order, rata-rata Rp874.750,00
1-14 Jan: omzet Rp2.749.000,00, 3 order, rata-rata Rp916.333,33
Februari: omzet Rp925.000,00, 1 order, rata-rata Rp925.000,00

Produk terlaris:
  1. Mouse Wireless Ergonomis (MS-200) qty 6 — omzet Rp1.500.000,00
  2. Keyboard Mekanik RGB     (KB-100) qty 2 — omzet Rp1.500.000,00
  3. Headset Stereo Bluetooth (HD-300) qty 2 — omzet Rp999.000,00

Pelanggan aktif:
  1. Siti Nurhaliza 2 order — total Rp1.500.000,00
  2. Budi Santoso   1 order — total Rp1.000.000,00
  3. Andi Wijaya    1 order — total Rp999.000,00

Cocokkan output dengan definisi STATUS_TERJUAL. Order monitor senilai Rp1.850.000,00 milik Dewi tidak pernah muncul di omzet Januari karena statusnya masih pending; order USB-C Hub dan Webcam milik Budi juga absen karena batal. Omzet Rp3.499.000,00 adalah jumlah tepat empat order Januari yang terbayar, dan rata-ratanya Rp874.750,00 — hasil intdiv(349900000, 4) tanpa satu byte float pun. Rentang 1-14 Januari memangkas order tanggal 25, dan Februari hanya menangkap satu order tanggal 2 — BETWEEN bekerja inklusif di kedua ujung.

Papan produk terlaris menunjukkan pengurai bekerjanya: Mouse dan Keyboard sama-sama menyumbang omzet Rp1.500.000,00, tetapi Mouse unggul qty (6 lawan 2); Keyboard dan Headset sama-sama qty 2, tetapi Keyboard unggul omzet. Tanpa aturan pengurai, dua posisi terakhir itu bisa bertukar tempat antar eksekusi. Di papan pelanggan, Siti memimpin karena dua ordernya terbayar, sementara order Budi yang dibatalkan tidak menambah angka apa pun — pembatalan memang bukan loyalitas.

Jejak Audit yang Append-Only #

stock_movements dari Bagian 4 sesungguhnya sudah adalah audit log — untuk satu entitas, yaitu stok. AuditLogger menggeneralisasi gagasan itu untuk semua entitas: setiap peristiwa penting (order dibuat, status berubah, pembayaran masuk) menjadi satu baris baru pada tabel audit_log, dengan snapshot data sebelum dan sesudah peristiwa disimpan sebagai JSON. Dua aturan bentuknya:

  • Append-only — baris audit hanya boleh ditambah, tidak pernah diubah atau dihapus. Mengedit jejak audit sama saja dengan memalsukan buku kas; seluruh nilai audit justru lahir dari janji bahwa masa lalu tidak bisa ditulis ulang.
  • Snapshot, bukan referensi hidup — kolom data_sebelum/data_sesudah menyimpan salinan nilai saat peristiwa terjadi. Baris order boleh berubah ratusan kali setelahnya; jejak audit tetap menunjukkan keadaan persis pada momen itu.

Skema tabelnya sederhana — tidak ada kolom updated_at karena tidak ada yang boleh diperbarui:

sql
CREATE TABLE IF NOT EXISTS audit_log (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    aksi         TEXT    NOT NULL,
    entitas      TEXT    NOT NULL,
    entity_id    INTEGER NOT NULL,
    data_sebelum TEXT,
    data_sesudah TEXT,
    waktu        TEXT    NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_audit_log_entitas ON audit_log(entitas, entity_id);

Index gabungan (entitas, entity_id) tepat sasaran karena hampir semua pembacaan audit adalah “putar ulang riwayat entitas ini” — index ini yang dilayani riwayat().

Class AuditLogger #

Berikut isi lengkap src/AuditLogger.php:

php
<?php

declare(strict_types=1);

/**
 * Pencatat audit append-only: setiap peristiwa penting domain ditulis
 * sebagai baris baru pada audit_log dan tidak pernah diubah atau dihapus.
 *
 * Snapshot data disimpan sebagai JSON (json_encode dengan
 * JSON_UNESCAPED_UNICODE) pada dua kolom opsional: data_sebelum dan
 * data_sesudah. Waktu dicatat eksplisit oleh pemanggil sebagai
 * DateTimeImmutable — di demo dan test selalu nilai beku agar output
 * deterministik; bila dikosongkan, waktu sekarang UTC yang dipakai
 * (sejajar dengan CURRENT_TIMESTAMP SQLite).
 */
final class AuditLogger
{
    public function __construct(private PDO $pdo)
    {
        $this->pdo->exec('PRAGMA foreign_keys = ON');
    }

    /**
     * Catat satu peristiwa audit dan kembalikan id barisnya.
     *
     * Contoh pemakaian pada perubahan status order:
     * $audit->record('order.ubah_status', 'order', 7,
     *     ['status' => 'pending'], ['status' => 'dibayar'], $waktuBeku);
     *
     * @param string                    $aksi         nama peristiwa, mis. 'order.dibuat'
     * @param string                    $entitas      nama tabel/entitas, mis. 'order'
     * @param int                       $entityId     id baris entitas
     * @param array<string, mixed>|null $dataSebelum  snapshot sebelum peristiwa (null bila tidak relevan)
     * @param array<string, mixed>|null $dataSesudah  snapshot sesudah peristiwa (null bila tidak relevan)
     * @param DateTimeImmutable|null    $waktu        waktu peristiwa; null = sekarang (UTC)
     */
    public function record(
        string $aksi,
        string $entitas,
        int $entityId,
        ?array $dataSebelum = null,
        ?array $dataSesudah = null,
        ?DateTimeImmutable $waktu = null,
    ): int {
        $waktu ??= new DateTimeImmutable('now', new DateTimeZone('UTC'));

        $stmt = $this->pdo->prepare(
            'INSERT INTO audit_log (aksi, entitas, entity_id, data_sebelum, data_sesudah, waktu)
             VALUES (?, ?, ?, ?, ?, ?)'
        );
        $stmt->execute([
            $aksi,
            $entitas,
            $entityId,
            $dataSebelum === null ? null : json_encode($dataSebelum, JSON_UNESCAPED_UNICODE),
            $dataSesudah === null ? null : json_encode($dataSesudah, JSON_UNESCAPED_UNICODE),
            $waktu->format('Y-m-d H:i:s'),
        ]);

        return (int) $this->pdo->lastInsertId();
    }

    /**
     * Putar ulang riwayat satu entitas: seluruh baris audit untuk
     * (entitas, id) tersebut, urut dari peristiwa paling lama.
     *
     * @return list<array<string, mixed>>
     */
    public function riwayat(string $entitas, int $entityId): array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, aksi, entitas, entity_id, data_sebelum, data_sesudah, waktu
               FROM audit_log
              WHERE entitas = ? AND entity_id = ?
              ORDER BY id'
        );
        $stmt->execute([$entitas, $entityId]);

        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }
}

Tiga keputusan pada class ini perlu dijelaskan. Pertama, snapshot dibungkus json_encode() dengan flag JSON_UNESCAPED_UNICODE — tanpa flag itu, nama “Budi Santoso” tersimpan aman, tetapi teks non-ASCII apa pun akan menjadi deretan \uXXXX yang menyulitkan pembacaan ulang oleh manusia. Kedua, record() tidak menerima DateTimeImmutable wajib melainkan opsional dengan fallback waktu sekarang UTC: kelonggaran ini membuat pemanggil produksi tidak perlu membangun objek waktu hanya untuk “sekarang”, sementara demo dan test tetap wajib mengirim nilai beku bila ingin output yang deterministik. Ketiga, tidak ada method hapus() atau ubah() — kelas ini secara struktural tidak mungkin memalsukan sejarah.

Walkthrough Audit dan Replay #

Walkthrough ini menjalankan satu order lewat jalur bisnis sungguhan, mencatat peristiwanya ke audit log pada titik-titik penting, lalu memutar ulang seluruh riwayatnya:

php
<?php
require __DIR__ . '/../bagian-2-order-dan-order-items/src/OrderRepository.php';
require __DIR__ . '/../bagian-3-order-processing/src/OrderService.php';
require __DIR__ . '/src/AuditLogger.php';

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-model-pelanggan-dan-produk/schema.sql'));
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS audit_log (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    aksi         TEXT    NOT NULL,
    entitas      TEXT    NOT NULL,
    entity_id    INTEGER NOT NULL,
    data_sebelum TEXT,
    data_sesudah TEXT,
    waktu        TEXT    NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_audit_log_entitas ON audit_log(entitas, entity_id);
SQL);

$repo = new OrderRepository($pdo);
$service = new OrderService($pdo, $repo);
$audit = new AuditLogger($pdo);

$utc = new DateTimeZone('UTC');

$orderId = $service->placeOrder(1, [[1, 1]]);
$order = $repo->cariId($orderId);
$audit->record('order.dibuat', 'order', $orderId, null, [
    'customer_id' => (int) $order['customer_id'],
    'status'      => $order['status'],
    'item'        => count($order['items']),
    'total'       => $order['total'],
], new DateTimeImmutable('2026-01-05 09:30:00', $utc));

$sebelum = ['status' => $order['status']];
$service->ubahStatus($orderId, 'dibayar');
$audit->record('order.ubah_status', 'order', $orderId, $sebelum, ['status' => 'dibayar'],
    new DateTimeImmutable('2026-01-05 09:31:12', $utc));

$service->ubahStatus($orderId, 'dikirim');
$audit->record('order.ubah_status', 'order', $orderId, ['status' => 'dibayar'], ['status' => 'dikirim'],
    new DateTimeImmutable('2026-01-06 08:02:40', $utc));

echo "Replay riwayat order #$orderId:\n";
foreach ($audit->riwayat('order', $orderId) as $b) {
    printf("%s  %-17s order #%d\n", $b['waktu'], $b['aksi'], $b['entity_id']);
    printf("    sebelum: %s\n", $b['data_sebelum'] ?? '-');
    printf("    sesudah: %s\n", $b['data_sesudah'] ?? '-');
}
printf("Riwayat entitas tak dikenal: %d baris\n", count($audit->riwayat('order', 99)));

Output yang Dihasilkan:

text
Replay riwayat order #1:
2026-01-05 09:30:00  order.dibuat      order #1
    sebelum: -
    sesudah: {"customer_id":1,"status":"pending","item":1,"total":75000000}
2026-01-05 09:31:12  order.ubah_status order #1
    sebelum: {"status":"pending"}
    sesudah: {"status":"dibayar"}
2026-01-06 08:02:40  order.ubah_status order #1
    sebelum: {"status":"dibayar"}
    sesudah: {"status":"dikirim"}
Riwayat entitas tak dikenal: 0 baris

Replay membaca seperti berita acara: pukul 09:30 order lahir sebagai pending senilai 75.000.000 sen dengan satu item; 72 detik kemudian uang masuk dan status menjadi dibayar; keesokan paginya paket berangkat dan status menjadi dikirim. Setiap baris memuat keadaan sebelum dan sesudah, sehingga pertanyaan “status apa sebelum dibayar?” dijawab tanpa harus menebak dari urutan. Baris terakhir membuktikan sifat kueri: entitas yang tak dikenal menghasilkan nol baris, bukan error.

Menjaga Append-Only di Level Database #

Aturan “jangan pernah mengubah audit_log” saat ini hanya hidup di disiplin pemrograman — satu query UPDATE yang lupa bisa merusaknya diam-diam. SQLite memungkinkan kita menegakkannya di level database lewat trigger BEFORE UPDATE dan BEFORE DELETE yang memanggil RAISE(ABORT, ...):

php
<?php
// Demo mandiri: skema audit_log + trigger penjaga, tanpa class proyek.
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec(<<<'SQL'
CREATE TABLE audit_log (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    aksi         TEXT    NOT NULL,
    entitas      TEXT    NOT NULL,
    entity_id    INTEGER NOT NULL,
    data_sebelum TEXT,
    data_sesudah TEXT,
    waktu        TEXT    NOT NULL
);
CREATE TRIGGER audit_log_no_update
BEFORE UPDATE ON audit_log
BEGIN
    SELECT RAISE(ABORT, 'audit_log bersifat append-only');
END;
CREATE TRIGGER audit_log_no_delete
BEFORE DELETE ON audit_log
BEGIN
    SELECT RAISE(ABORT, 'audit_log bersifat append-only');
END;
INSERT INTO audit_log (aksi, entitas, entity_id, data_sesudah, waktu)
VALUES ('order.dibuat', 'order', 1, '{"status":"pending"}', '2026-01-05 09:30:00');
SQL);

try {
    $pdo->exec("UPDATE audit_log SET aksi = 'dimanipulasi' WHERE id = 1");
} catch (PDOException $e) {
    echo "UPDATE ditolak: {$e->getMessage()}\n";
}

try {
    $pdo->exec('DELETE FROM audit_log WHERE id = 1');
} catch (PDOException $e) {
    echo "DELETE ditolak: {$e->getMessage()}\n";
}

printf("Baris audit tetap utuh: %d\n",
    (int) $pdo->query('SELECT COUNT(*) FROM audit_log')->fetchColumn());

Output yang Dihasilkan:

text
UPDATE ditolak: SQLSTATE[23000]: Integrity constraint violation: 19 audit_log bersifat append-only
DELETE ditolak: SQLSTATE[23000]: Integrity constraint violation: 19 audit_log bersifat append-only
Baris audit tetap utuh: 1

Kedua percobaan manipulasi ditolak dengan exception sebelum satu baris pun berubah, dan penghitungan akhir membuktikan barisnya tetap ada. Trigger seperti ini layak dipasang pada migrasi produksi — biaya periksa yang kecil untuk jaminan yang mahal. Di MySQL/PostgreSQL, mekanisme setara adalah mencabut privilege UPDATE/DELETE pada tabel audit dari user aplikasi; prinsipnya sama, yaitu penegakan tidak bergantung pada kebaikan kode.

Cara Menjalankan Contoh #

Contoh pada bagian ini membutuhkan berkas dari Bagian 1-3. Susun foldernya sebagai berikut:

text
sistem-pemesanan/
├── bagian-1-model-pelanggan-dan-produk/
│   └── schema.sql
├── bagian-2-order-dan-order-items/
│   └── src/
│       └── OrderRepository.php
├── bagian-3-order-processing/
│   └── src/
│       └── OrderService.php
└── bagian-6-reporting-dan-audit/
    ├── src/
    │   ├── ReportService.php
    │   └── AuditLogger.php
    └── uji-laporan.php

Simpan walkthrough laporan penjualan sebagai uji-laporan.php di dalam folder bagian-6-reporting-dan-audit, lalu jalankan:

bash
php uji-laporan.php

Snippet trigger membangun skemanya sendiri di :memory: dan tidak membutuhkan berkas class mana pun. Dua walkthrough lainnya memuat schema Bagian 1 serta class dari Bagian 2 dan 3 lewat path relatif, jadi struktur folder antarbagian harus dipertahankan. Seluruh contoh memakai :memory: dan tidak meninggalkan berkas database; teknik backdate hanya menyentuh kolom created_at di database sementara tersebut.

Apa yang Berubah #

Dibandingkan Bagian 5 yang mengalirkan uang, proyek kini bisa menjawab pertanyaan dari datanya:

  • Berkas src/ReportService.php baru dengan tiga laporan: penjualanPerPeriode() (omzet, jumlah order, rata-rata — semua integer sen), produkTerlaris() (top N dengan pengurai deterministik), dan pelangganAktif() (pelanggan dengan order terjual terbanyak).
  • Berkas src/AuditLogger.php baru dengan record() (append-only + snapshot JSON + waktu beku/deterministik) dan riwayat() (replay per entitas memakai index gabungan).
  • Konvensi baru terdokumentasi: waktu disimpan sebagai string UTC agar sejajar dengan CURRENT_TIMESTAMP SQLite, dan teknik seeding data dengan created_at beku untuk demo yang deterministik.
  • Trigger RAISE(ABORT) teruji sebagai penjaga append-only di level database.

Ringkasan #

Data yang ditulis dengan disiplin kini terbaca sebagai pengetahuan: omzet per periode, produk terlaris, dan pelanggan paling setia keluar dari JOIN dan agregasi yang deterministik, sementara setiap peristiwa penting meninggalkan baris audit yang bisa diputar ulang dan tidak mungkin ditulis ulang. Seluruh sifat yang kita klaim sepanjang proyek — atomisitas transaksi, idempotensi pembayaran, konsistensi stok — hingga kini dibuktikan lewat walkthrough manual. Bagian 7 menutup proyek dengan membuktikannya secara otomatis: PHPUnit menjalankan puluhan skenario dalam hitungan detik, dan Dockerfile mengemas semuanya menjadi image yang siap dijalankan.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

src/AuditLogger.php

Unduh berkas
php
<?php

declare(strict_types=1);

/**
 * Pencatat audit append-only: setiap peristiwa penting domain ditulis
 * sebagai baris baru pada audit_log dan tidak pernah diubah atau dihapus.
 *
 * Snapshot data disimpan sebagai JSON (json_encode dengan
 * JSON_UNESCAPED_UNICODE) pada dua kolom opsional: data_sebelum dan
 * data_sesudah. Waktu dicatat eksplisit oleh pemanggil sebagai
 * DateTimeImmutable — di demo dan test selalu nilai beku agar output
 * deterministik; bila dikosongkan, waktu sekarang UTC yang dipakai
 * (sejajar dengan CURRENT_TIMESTAMP SQLite).
 */
final class AuditLogger
{
    public function __construct(private PDO $pdo)
    {
        $this->pdo->exec('PRAGMA foreign_keys = ON');
    }

    /**
     * Catat satu peristiwa audit dan kembalikan id barisnya.
     *
     * Contoh pemakaian pada perubahan status order:
     * $audit->record('order.ubah_status', 'order', 7,
     *     ['status' => 'pending'], ['status' => 'dibayar'], $waktuBeku);
     *
     * @param string                   $aksi        nama peristiwa, mis. 'order.dibuat'
     * @param string                   $entitas     nama tabel/entitas, mis. 'order'
     * @param int                      $entityId    id baris entitas
     * @param array<string, mixed>|null $dataSebelum snapshot sebelum peristiwa (null bila tidak relevan)
     * @param array<string, mixed>|null $dataSesudah snapshot sesudah peristiwa (null bila tidak relevan)
     * @param DateTimeImmutable|null    $waktu       waktu peristiwa; null = sekarang (UTC)
     */
    public function record(
        string $aksi,
        string $entitas,
        int $entityId,
        ?array $dataSebelum = null,
        ?array $dataSesudah = null,
        ?DateTimeImmutable $waktu = null,
    ): int {
        $waktu ??= new DateTimeImmutable('now', new DateTimeZone('UTC'));

        $stmt = $this->pdo->prepare(
            'INSERT INTO audit_log (aksi, entitas, entity_id, data_sebelum, data_sesudah, waktu)
             VALUES (?, ?, ?, ?, ?, ?)'
        );
        $stmt->execute([
            $aksi,
            $entitas,
            $entityId,
            $dataSebelum === null ? null : json_encode($dataSebelum, JSON_UNESCAPED_UNICODE),
            $dataSesudah === null ? null : json_encode($dataSesudah, JSON_UNESCAPED_UNICODE),
            $waktu->format('Y-m-d H:i:s'),
        ]);

        return (int) $this->pdo->lastInsertId();
    }

    /**
     * Putar ulang riwayat satu entitas: seluruh baris audit untuk
     * (entitas, id) tersebut, urut dari peristiwa paling lama.
     *
     * @return list<array<string, mixed>>
     */
    public function riwayat(string $entitas, int $entityId): array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, aksi, entitas, entity_id, data_sebelum, data_sesudah, waktu
               FROM audit_log
              WHERE entitas = ? AND entity_id = ?
              ORDER BY id'
        );
        $stmt->execute([$entitas, $entityId]);

        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }
}

src/ReportService.php

Unduh berkas
php
<?php

declare(strict_types=1);

/**
 * Laporan penjualan dari data order yang sudah ada.
 *
 * ReportService murni membaca: tidak satu pun method mengubah baris
 * data, sehingga aman dipanggil kapan pun tanpa transaksi. Seluruh
 * nilai uang keluar dalam satuan sen (INTEGER), mengikuti konvensi
 * proyek; rata-rata dihitung dengan intdiv() sehingga tetap eksak
 * dalam integer (pembulatan ke bawah).
 */
final class ReportService
{
    /**
     * Status order yang dihitung sebagai penjualan: uang sudah benar-benar
     * masuk. Order pending (belum dibayar) dan batal (uang tidak masuk)
     * tidak pernah masuk laporan penjualan.
     */
    public const STATUS_TERJUAL = ['dibayar', 'dikirim', 'selesai'];

    public function __construct(private PDO $pdo)
    {
        $this->pdo->exec('PRAGMA foreign_keys = ON');
    }

    /**
     * Ringkasan penjualan satu periode waktu.
     *
     * Rentang dibandingkan terhadap orders.created_at secara inklusif di
     * kedua ujung (BETWEEN). Waktu disimpan sebagai string UTC format
     * 'Y-m-d H:i:s' — sama dengan CURRENT_TIMESTAMP SQLite — sehingga
     * perbandingan string langsung benar.
     *
     * @param DateTimeImmutable $dari    awal periode (inklusif)
     * @param DateTimeImmutable $sampai  akhir periode (inklusif)
     *
     * @return array{omzet: int, jumlahOrder: int, rataRata: int}
     *         seluruh nilai uang dalam sen; rataRata adalah intdiv(omzet,
     *         jumlahOrder) dan bernilai 0 bila tidak ada penjualan.
     */
    public function penjualanPerPeriode(DateTimeImmutable $dari, DateTimeImmutable $sampai): array
    {
        $stmt = $this->pdo->prepare(
            'SELECT COUNT(DISTINCT o.id) AS jumlah_order,
                    COALESCE(SUM(oi.qty * oi.harga), 0) AS omzet
               FROM orders o
               JOIN order_items oi ON oi.order_id = o.id
              WHERE o.status IN (' . $this->klausulStatusTerjual() . ')
                AND o.created_at BETWEEN ? AND ?'
        );
        $stmt->execute([$dari->format('Y-m-d H:i:s'), $sampai->format('Y-m-d H:i:s')]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);

        $jumlahOrder = (int) $row['jumlah_order'];
        $omzet = (int) $row['omzet'];

        return [
            'omzet'      => $omzet,
            'jumlahOrder' => $jumlahOrder,
            'rataRata'   => $jumlahOrder === 0 ? 0 : intdiv($omzet, $jumlahOrder),
        ];
    }

    /**
     * N produk terlaris berdasarkan total qty yang terjual (hanya order
     * berstatus terjual). Urutan: qty turun, lalu omzet turun, lalu id
     * naik — dua pengurai (tiebreaker) terakhir menjamin urutan yang
     * sama untuk data yang sama.
     *
     * @param int $n jumlah produk yang diminta
     *
     * @return list<array{id: int, sku: string, nama: string, qtyTerjual: int, omzet: int}>
     */
    public function produkTerlaris(int $n = 5): array
    {
        $stmt = $this->pdo->prepare(
            'SELECT p.id, p.sku, p.nama,
                    SUM(oi.qty) AS qty_terjual,
                    SUM(oi.qty * oi.harga) AS omzet
               FROM order_items oi
               JOIN orders o ON o.id = oi.order_id
               JOIN products p ON p.id = oi.product_id
              WHERE o.status IN (' . $this->klausulStatusTerjual() . ')
              GROUP BY p.id, p.sku, p.nama
              ORDER BY qty_terjual DESC, omzet DESC, p.id ASC
              LIMIT ?'
        );
        $stmt->execute([$n]);

        return array_map(
            static fn(array $r): array => [
                'id'         => (int) $r['id'],
                'sku'        => (string) $r['sku'],
                'nama'       => (string) $r['nama'],
                'qtyTerjual' => (int) $r['qty_terjual'],
                'omzet'      => (int) $r['omzet'],
            ],
            $stmt->fetchAll(PDO::FETCH_ASSOC)
        );
    }

    /**
     * Pelanggan paling aktif: diurutkan berdasarkan jumlah order terjual,
     * lalu total belanja, lalu id — semuanya turun/naik yang deterministik.
     *
     * @param int $n jumlah pelanggan yang diminta
     *
     * @return list<array{id: int, nama: string, email: string, jumlahOrder: int, totalBelanja: int}>
     */
    public function pelangganAktif(int $n = 5): array
    {
        $stmt = $this->pdo->prepare(
            'SELECT c.id, c.nama, c.email,
                    COUNT(DISTINCT o.id) AS jumlah_order,
                    COALESCE(SUM(oi.qty * oi.harga), 0) AS total_belanja
               FROM customers c
               JOIN orders o ON o.customer_id = c.id
               JOIN order_items oi ON oi.order_id = o.id
              WHERE o.status IN (' . $this->klausulStatusTerjual() . ')
              GROUP BY c.id, c.nama, c.email
              ORDER BY jumlah_order DESC, total_belanja DESC, c.id ASC
              LIMIT ?'
        );
        $stmt->execute([$n]);

        return array_map(
            static fn(array $r): array => [
                'id'           => (int) $r['id'],
                'nama'         => (string) $r['nama'],
                'email'        => (string) $r['email'],
                'jumlahOrder'  => (int) $r['jumlah_order'],
                'totalBelanja' => (int) $r['total_belanja'],
            ],
            $stmt->fetchAll(PDO::FETCH_ASSOC)
        );
    }

    /**
     * Daftar status terjual sebagai fragmen IN(...) — konstanta internal,
     * bukan masukan pengguna, jadi perangkai string di sini aman.
     */
    private function klausulStatusTerjual(): string
    {
        return "'" . implode("','", self::STATUS_TERJUAL) . "'";
    }
}