Repository dari Bagian 2 mengelola nama dan harga, tetapi menyentuh satu pun angka stok tidak bisa dilakukannya — dan memang begitu rancangnya. Pada bagian ini kita membangun mekanisme resmi perubahan stok: tabel stock_movements yang bekerja sebagai buku besar (ledger) dengan class StockMovementRepository sebagai satu-satunya penulisnya. Setiap kejadian inventaris — pembelian masuk, penjualan keluar — dicatat sebagai baris baru yang tidak pernah diubah; kolom stok_sekarang di tabel products kita anggap angka hasil hitung yang kebenarannya selalu bisa diperiksa ulang dari catatan ini. Dua hal menjadi fokus: pola append-only yang membuat riwayat bisa dipercaya, dan guard yang menolak movement keluar ketika stok tidak cukup — dicek sebelum satu baris pun ditulis.

Kenapa Buku Besar, Bukan Sekadar Update Stok #

Cara paling naif mengelola stok adalah satu kolom yang terus di-update: UPDATE products SET stok_sekarang = stok_sekarang - 5. Cara ini murah, tetapi setelah update selesai, jejaknya lenyap — tidak ada yang tahu kenapa stok berkurang, kapan, dan oleh transaksi apa. Ketika pemilik toko bertanya “stok BISK-003 kok tinggal 45, kemana 5 yang hilang?”, kolom tunggal tidak punya jawaban. Buku besar memilikinya: masuk 50 pada 6 Januari lewat PO-2601-002, keluar 5 pada 8 Januari lewat INV-2601-101.

Buku besar juga memberi sesuatu yang tidak dimiliki kolom tunggal: kemampuan direkonsiliasi. Karena stok_sekarang seharusnya selalu sama dengan total masuk dikurangi total keluar, selisih di antara keduanya adalah alarm bahwa ada yang salah — bug, penulisan setengah jadi, atau kecurangan. Pada bagian paling bawah halaman ini kita akan melihat alarm itu berbunyi dengan sengaja, sebagai jembatan menuju transaksi database pada Bagian 4.

Class StockMovementRepository #

Berikut isi lengkap src/StockMovementRepository.php sebagaimana tersedia pada berkas sumber di bagian bawah halaman, termasuk exception domain yang didefinisikan pada berkas yang sama:

php
<?php

declare(strict_types=1);

/**
 * Exception domain: movement keluar ditolak karena stok tidak cukup.
 *
 * Exception ini memisahkan "input salah" (InvalidArgumentException,
 * kesalahan pemanggil) dari "aturan bisnis dilanggar" (stok habis,
 * kondisi data yang sehat namun tidak memenuhi syarat operasi).
 */
final class StokTidakCukupException extends RuntimeException
{
}

/**
 * Lapisan akses data untuk tabel stock_movements - buku besar inventaris.
 *
 * Tabel ini append-only: method di class ini hanya menambah baris dan
 * membaca riwayat, tidak pernah mengubah atau menghapus catatan lama.
 * Method catat() sengaja tidak menyentuh kolom products.stok_sekarang;
 * penyatuan "catat movement + perbarui stok" sebagai satu kesatuan
 * atomik adalah tugas InventoryService pada Bagian 4.
 */
final class StockMovementRepository
{
    public function __construct(private PDO $pdo)
    {
        $this->pdo->exec('PRAGMA foreign_keys = ON');
    }

    /**
     * Catat satu movement baru, kembalikan id barisnya.
     *
     * Guard stok diperiksa SEBELUM baris ditulis: movement keluar yang
     * akan membuat stok negatif ditolak dengan StokTidakCukupException
     * sehingga buku besar tidak pernah memuat transaksi mustahil.
     *
     * Parameter $dibuatPada opsional untuk demo dan test agar output
     * deterministik; berikan null (default) pada aplikasi nyata agar
     * database mengisi waktu server lewat DEFAULT CURRENT_TIMESTAMP.
     *
     * @param string $jenis 'masuk' atau 'keluar'
     * @throws InvalidArgumentException bila jenis tidak dikenal, jumlah
     *         tidak positif, atau produk tidak ditemukan
     * @throws StokTidakCukupException bila keluar melebihi stok tersedia
     */
    public function catat(
        int $productId,
        string $jenis,
        int $jumlah,
        ?string $keterangan = null,
        ?string $dibuatPada = null,
    ): int {
        if (!in_array($jenis, ['masuk', 'keluar'], true)) {
            throw new InvalidArgumentException(
                "Jenis movement harus 'masuk' atau 'keluar', diberikan: '$jenis'."
            );
        }
        if ($jumlah <= 0) {
            throw new InvalidArgumentException(
                "Jumlah movement harus bilangan bulat positif, diberikan: $jumlah."
            );
        }

        $produk = $this->ambilProduk($productId);
        if ($produk === null) {
            throw new InvalidArgumentException("Produk #$productId tidak ditemukan.");
        }

        if ($jenis === 'keluar' && (int) $produk['stok_sekarang'] < $jumlah) {
            throw new StokTidakCukupException(sprintf(
                'Stok %s (%s) tidak cukup: tersisa %d, diminta %d.',
                $produk['nama'],
                $produk['sku'],
                (int) $produk['stok_sekarang'],
                $jumlah
            ));
        }

        $stmt = $this->pdo->prepare(
            'INSERT INTO stock_movements (product_id, jenis, jumlah, keterangan, dibuat_pada)
             VALUES (?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP))'
        );
        $stmt->execute([$productId, $jenis, $jumlah, $keterangan, $dibuatPada]);

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

    /**
     * Ambil seluruh movement satu produk, urut dari yang terlama.
     *
     * @return list<array<string, mixed>> elemen id, jenis, jumlah,
     *         keterangan, dibuat_pada
     */
    public function riwayatByProduct(int $productId): array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, jenis, jumlah, keterangan, dibuat_pada
               FROM stock_movements
              WHERE product_id = ?
              ORDER BY id'
        );
        $stmt->execute([$productId]);

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

    /**
     * Total jumlah movement per jenis untuk satu produk.
     *
     * @return array{masuk: int, keluar: int}
     */
    public function totalsByJenis(int $productId): array
    {
        $stmt = $this->pdo->prepare(
            "SELECT COALESCE(SUM(CASE WHEN jenis = 'masuk' THEN jumlah END), 0)  AS total_masuk,
                    COALESCE(SUM(CASE WHEN jenis = 'keluar' THEN jumlah END), 0) AS total_keluar
               FROM stock_movements
              WHERE product_id = ?"
        );
        $stmt->execute([$productId]);
        $baris = $stmt->fetch(PDO::FETCH_ASSOC);

        return [
            'masuk' => (int) $baris['total_masuk'],
            'keluar' => (int) $baris['total_keluar'],
        ];
    }

    /**
     * Hitung stok seharusnya menurut buku besar: total masuk dikurangi keluar.
     *
     * Dibandingkan dengan products.stok_sekarang, method ini menjadi alat
     * rekonsiliasi: kedua nilai harus selalu sama pada database yang sehat.
     */
    public function stokMenurutLedger(int $productId): int
    {
        $stmt = $this->pdo->prepare(
            "SELECT COALESCE(SUM(CASE WHEN jenis = 'masuk' THEN jumlah ELSE -jumlah END), 0)
               FROM stock_movements
              WHERE product_id = ?"
        );
        $stmt->execute([$productId]);

        return (int) $stmt->fetchColumn();
    }

    /**
     * Ambil sku, nama, dan stok_sekarang satu produk; null bila tidak ada.
     *
     * @return array<string, mixed>|null
     */
    private function ambilProduk(int $productId): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT sku, nama, stok_sekarang FROM products WHERE id = ?'
        );
        $stmt->execute([$productId]);
        $baris = $stmt->fetch(PDO::FETCH_ASSOC);

        return $baris === false ? null : $baris;
    }
}

Keputusan-keputusan penting pada class tersebut:

  1. catat() adalah satu-satunya penulis buku besar. Validasi input (jenis dikenal, jumlah positif, produk ada) berjalan di PHP dengan pesan yang jelas, lalu guard stok memeriksa stok_sekarang sebelum INSERT — penolakan tidak meninggalkan baris apa pun. in_array(..., true) memakai perbandingan ketat agar 'Masuk' berhuruf besar tidak lolos.
  2. StokTidakCukupException adalah exception domain, bukan InvalidArgumentException: parameternya sah, tetapi kondisi bisnisnya tidak terpenuhi. Pemanggil bisa menangkapnya secara spesifik untuk menampilkan “stok kurang” tanpa menelan galat pemrograman lain.
  3. COALESCE(?, CURRENT_TIMESTAMP) pada INSERT memberi dua mode waktu: berikan string jam beku untuk demo dan test (agar output deterministik), atau null agar database mengisi waktu server sendiri.
  4. catat() tidak menyentuh products.stok_sekarang. Pencatatan di buku besar dan pembaruan kolom stok adalah dua penulisan yang harus terjadi bersamaan — menyatukannya secara atomik adalah masalah yang dijawab transaksi database pada Bagian 4.
  5. totalsByJenis() dan stokMenurutLedger() menghitung agregat dengan SUM bersyarat; COALESCE(..., 0) menjaga produk tanpa movement tetap menghasilkan nol, bukan null.

Walkthrough Riwayat dan Totals #

Kita mulai dari produk dengan riwayat paling menarik: BISK-003 yang di-seed dengan masuk 50 dan keluar 5 pada Bagian 1. Method riwayatByProduct(), totalsByJenis(), dan stokMenurutLedger() membaca cerita itu kembali:

php
<?php
require __DIR__ . '/src/StockMovementRepository.php';
require __DIR__ . '/../bagian-2-produk-dan-kategori/src/ProductRepository.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-desain-database/schema.sql'));

$products = new ProductRepository($pdo);
$moves = new StockMovementRepository($pdo);

$biskuit = $products->cariSku('BISK-003');
echo "Riwayat BISK-003 ({$biskuit['nama']}):\n";
foreach ($moves->riwayatByProduct(3) as $m) {
    printf(
        "  #%-2d %-6s %3d  %s\n",
        $m['id'],
        $m['jenis'],
        $m['jumlah'],
        $m['keterangan']
    );
}

$totals = $moves->totalsByJenis(3);
echo "\nTotals per jenis:\n";
echo "  masuk : {$totals['masuk']}\n";
echo "  keluar: {$totals['keluar']}\n";

$ledger = $moves->stokMenurutLedger(3);
echo "\nstokMenurutLedger(3) = $ledger\n";
echo "products.stok_sekarang = {$biskuit['stok_sekarang']}\n";
echo $ledger === (int) $biskuit['stok_sekarang'] ? "Konsisten - buku besar dan kolom stok saling cocok\n" : "TIDAK konsisten!\n";

Output yang Dihasilkan:

text
Riwayat BISK-003 (Biskuit Cokelat 120g):
  #3  masuk   50  Pembelian awal PO-2601-002
  #4  keluar   5  Penjualan INV-2601-101

Totals per jenis:
  masuk : 50
  keluar: 5

stokMenurutLedger(3) = 45
products.stok_sekarang = 45
Konsisten - buku besar dan kolom stok saling cocok

Riwayat tampil urut dari kejadian terlama karena ORDER BY id — id yang naik sejalan waktu membuat urutan ini stabil meski dua movement terjadi pada detik yang sama. Totals per jenis (50 masuk, 5 keluar) dan stokMenurutLedger() (45) persis seperti yang dijanjikan seed pada Bagian 1, dan angka itu cocok dengan kolom stok_sekarang yang dibaca ProductRepository. Dua repository bekerja berdampingan di satu koneksi tanpa saling mengganggu.

Mencatat Movement Baru #

Sekarang giliran menulis. Kita catat pembelian 25 unit BISK-003 dengan jam beku, lalu periksa apa yang berubah:

php
<?php
require __DIR__ . '/src/StockMovementRepository.php';
require __DIR__ . '/../bagian-2-produk-dan-kategori/src/ProductRepository.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-desain-database/schema.sql'));

$products = new ProductRepository($pdo);
$moves = new StockMovementRepository($pdo);

$id = $moves->catat(3, 'masuk', 25, 'Pembelian PO-2601-099', '2026-01-20 09:30:00');
echo "catat(masuk 25) menghasilkan movement #$id\n";

echo "\nRiwayat BISK-003 sekarang:\n";
foreach ($moves->riwayatByProduct(3) as $m) {
    printf(
        "  #%-2d %-6s %3d  %s (%s)\n",
        $m['id'],
        $m['jenis'],
        $m['jumlah'],
        $m['keterangan'],
        $m['dibuat_pada']
    );
}

$ledger = $moves->stokMenurutLedger(3);
$biskuit = $products->cariSku('BISK-003');
echo "\nstokMenurutLedger(3)       = $ledger\n";
echo "products.stok_sekarang     = {$biskuit['stok_sekarang']}\n";
echo "Keduanya kini berbeda - kolom stok belum ikut berubah.\n";

Output yang Dihasilkan:

text
catat(masuk 25) menghasilkan movement #10

Riwayat BISK-003 sekarang:
  #3  masuk   50  Pembelian awal PO-2601-002 (2026-01-06 09:00:00)
  #4  keluar   5  Penjualan INV-2601-101 (2026-01-08 10:12:00)
  #10 masuk   25  Pembelian PO-2601-099 (2026-01-20 09:30:00)

stokMenurutLedger(3)       = 70
products.stok_sekarang     = 45
Keduanya kini berbeda - kolom stok belum ikut berubah.

Movement baru mendapat id 10 (sembilan baris seed sebelumnya), dan buku besar kini berkata 70. Tetapi lihat dua baris terakhir: stokMenurutLedger(3) = 70 sedangkan products.stok_sekarang masih 45. Ini bukan bug yang lolos — ini konsekuensi desain yang sengaja kita tangguhkan penyelesaiannya. catat() menulis buku besar; memperbarui kolom stok adalah penulisan kedua pada tabel lain. Dua penulisan yang harus terjadi bersamaan, dan bila salah satunya gagal di tengah jalan, database kita berakhir persis seperti output di atas: konsisten-kah? Tidak. Inilah pekerjaan yang hanya bisa dijawab oleh transaksi database, dan InventoryService pada Bagian 4 akan kita bangun tepat untuk itu.

Guard Menolak Stok Negatif #

Guard pada catat() punya tiga lapis penolakan: input yang salah bentuk, produk yang tidak ada, dan stok yang tidak cukup. Empat percobaan berikut menyentuh semuanya:

php
<?php
require __DIR__ . '/src/StockMovementRepository.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-desain-database/schema.sql'));

$moves = new StockMovementRepository($pdo);

function coba(string $label, callable $fn): void
{
    try {
        $fn();
        echo "$label: lolos (tidak ditolak)\n";
    } catch (Throwable $e) {
        echo "$label ditolak: [" . get_class($e) . "]\n";
        echo "  {$e->getMessage()}\n";
    }
}

coba('keluar 999 unit ', fn() => $moves->catat(3, 'keluar', 999, 'Penjualan besar-besaran'));
coba('jenis rusak    ', fn() => $moves->catat(1, 'rusak', 5, 'salah ketik'));
coba('jumlah nol     ', fn() => $moves->catat(1, 'masuk', 0, 'salah isi'));
coba('produk asing   ', fn() => $moves->catat(999, 'masuk', 5, 'produk tidak ada'));

$jumlah = $moves->riwayatByProduct(3);
echo "\nJumlah movement BISK-003 setelah empat penolakan: " . count($jumlah) . "\n";

Output yang Dihasilkan:

text
keluar 999 unit  ditolak: [StokTidakCukupException]
  Stok Biskuit Cokelat 120g (BISK-003) tidak cukup: tersisa 45, diminta 999.
jenis rusak     ditolak: [InvalidArgumentException]
  Jenis movement harus 'masuk' atau 'keluar', diberikan: 'rusak'.
jumlah nol      ditolak: [InvalidArgumentException]
  Jumlah movement harus bilangan bulat positif, diberikan: 0.
produk asing    ditolak: [InvalidArgumentException]
  Produk #999 tidak ditemukan.

Jumlah movement BISK-003 setelah empat penolakan: 2

Percobaan pertama menunjukkan wujud StokTidakCukupException: pesannya menyebut nama produk, SKU, stok tersisa, dan jumlah yang diminta — semua yang dibutuhkan kasir untuk menjelaskan penolakan kepada pembeli. Tiga percobaan lainnya adalah InvalidArgumentException dengan pesan yang menyebut persis apa yang salah. Baris terakhir adalah bukti terpenting: empat penolakan tidak meninggalkan satu baris pun — jumlah movement BISK-003 tetap 2, karena guard dievaluasi sebelum INSERT, bukan sesudah. Dibandingkan dengan CHECK constraint pada schema yang menolak jumlah > 0 di tingkat baris, guard PHP memberi pesan yang lebih kaya; keduanya tetap berdiri berlapis sebagaimana dicatat Bagian 1.

Cara Menjalankan Contoh #

Contoh pada bagian ini membutuhkan berkas dari dua bagian sebelumnya. Susun foldernya sebagai berikut:

text
sistem-inventaris/
├── bagian-1-desain-database/
│   └── schema.sql
├── bagian-2-produk-dan-kategori/
│   └── src/
│       └── ProductRepository.php
└── bagian-3-stock-movements/
    ├── src/
    │   └── StockMovementRepository.php
    └── uji-movement.php

Simpan salah satu walkthrough di atas sebagai uji-movement.php di dalam folder bagian-3-stock-movements, lalu jalankan:

bash
php uji-movement.php

Snippet memuat class lewat require __DIR__ . '/src/StockMovementRepository.php' untuk berkas milik bagian ini, dan require __DIR__ . '/../bagian-2-produk-dan-kategori/src/ProductRepository.php' untuk berkas bagian saudara — dua tingkat folder proyek kini saling terhubung, dan struktur ini dipertahankan sampai bagian terakhir. Berkas StockMovementRepository.php sendiri hanya berisi definisi dua class (StokTidakCukupException dan repository-nya), sehingga aman di-require tanpa efek samping.

Apa yang Berubah #

Proyek kini memiliki mekanisme resmi perubahan stok:

  • Berkas src/StockMovementRepository.php baru dengan empat method publik: catat(), riwayatByProduct(), totalsByJenis(), stokMenurutLedger(), plus exception domain StokTidakCukupException.
  • Guard stok berjalan sebelum penulisan: movement keluar yang melebihi stok tersedia ditolak tanpa meninggalkan jejak, dengan pesan yang menyebut SKU, stok tersisa, dan jumlah diminta.
  • Validasi input berganda: jenis movement dan jumlah diperiksa di PHP dengan pesan ramah, sementara CHECK constraint dari Bagian 1 tetap menjaga di tingkat baris.
  • Parameter $dibuatPada opsional membuat jam bisa dibekukan untuk demo dan test; null berarti database mengisi waktu server.
  • Batas arsitektur terdokumentasi dan terbukti: catat() hanya menulis buku besar sehingga stok_sekarang tertinggal — dua baris output terakhir walkthrough kedua adalah pengakuan jujur atas celah yang ditutup Bagian 4.

Ringkasan #

Buku besar inventaris kini hidup: setiap kejadian masuk-keluar tercatat sebagai baris append-only dengan penolakan yang bekerja sebelum penulisan, dan rekonsiliasi antara ledger serta kolom stok tinggal memanggil stokMenurutLedger(). Kita juga melihat dengan mata kepala sendiri bahayanya menulis buku besar tanpa memperbarui stok — dua sumber data yang saling meninggalkan. Dua penulisan pada dua tabel itu harus menjadi satu kesatuan yang gagal atau berhasil bersama-sama, dan itu persis definisi transaksi database. Bagian 4 membangun InventoryService yang membungkus keduanya dalam beginTransaction()/commit()/rollBack(), lalu melengkapinya dengan Validator agar data produk yang buruk tertahan di gerbang paling depan.

Referensi #

Berkas Sumber

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

src/StockMovementRepository.php

Unduh berkas
php
<?php

declare(strict_types=1);

/**
 * Exception domain: movement keluar ditolak karena stok tidak cukup.
 *
 * Exception ini memisahkan "input salah" (InvalidArgumentException,
 * kesalahan pemanggil) dari "aturan bisnis dilanggar" (stok habis,
 * kondisi data yang sehat namun tidak memenuhi syarat operasi).
 */
final class StokTidakCukupException extends RuntimeException
{
}

/**
 * Lapisan akses data untuk tabel stock_movements - buku besar inventaris.
 *
 * Tabel ini append-only: method di class ini hanya menambah baris dan
 * membaca riwayat, tidak pernah mengubah atau menghapus catatan lama.
 * Method catat() sengaja tidak menyentuh kolom products.stok_sekarang;
 * penyatuan "catat movement + perbarui stok" sebagai satu kesatuan
 * atomik adalah tugas InventoryService pada Bagian 4.
 */
final class StockMovementRepository
{
    public function __construct(private PDO $pdo)
    {
        $this->pdo->exec('PRAGMA foreign_keys = ON');
    }

    /**
     * Catat satu movement baru, kembalikan id barisnya.
     *
     * Guard stok diperiksa SEBELUM baris ditulis: movement keluar yang
     * akan membuat stok negatif ditolak dengan StokTidakCukupException
     * sehingga buku besar tidak pernah memuat transaksi mustahil.
     *
     * Parameter $dibuatPada opsional untuk demo dan test agar output
     * deterministik; berikan null (default) pada aplikasi nyata agar
     * database mengisi waktu server lewat DEFAULT CURRENT_TIMESTAMP.
     *
     * @param string $jenis 'masuk' atau 'keluar'
     * @throws InvalidArgumentException bila jenis tidak dikenal, jumlah
     *         tidak positif, atau produk tidak ditemukan
     * @throws StokTidakCukupException bila keluar melebihi stok tersedia
     */
    public function catat(
        int $productId,
        string $jenis,
        int $jumlah,
        ?string $keterangan = null,
        ?string $dibuatPada = null,
    ): int {
        if (!in_array($jenis, ['masuk', 'keluar'], true)) {
            throw new InvalidArgumentException(
                "Jenis movement harus 'masuk' atau 'keluar', diberikan: '$jenis'."
            );
        }
        if ($jumlah <= 0) {
            throw new InvalidArgumentException(
                "Jumlah movement harus bilangan bulat positif, diberikan: $jumlah."
            );
        }

        $produk = $this->ambilProduk($productId);
        if ($produk === null) {
            throw new InvalidArgumentException("Produk #$productId tidak ditemukan.");
        }

        if ($jenis === 'keluar' && (int) $produk['stok_sekarang'] < $jumlah) {
            throw new StokTidakCukupException(sprintf(
                'Stok %s (%s) tidak cukup: tersisa %d, diminta %d.',
                $produk['nama'],
                $produk['sku'],
                (int) $produk['stok_sekarang'],
                $jumlah
            ));
        }

        $stmt = $this->pdo->prepare(
            'INSERT INTO stock_movements (product_id, jenis, jumlah, keterangan, dibuat_pada)
             VALUES (?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP))'
        );
        $stmt->execute([$productId, $jenis, $jumlah, $keterangan, $dibuatPada]);

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

    /**
     * Ambil seluruh movement satu produk, urut dari yang terlama.
     *
     * @return list<array<string, mixed>> elemen id, jenis, jumlah,
     *         keterangan, dibuat_pada
     */
    public function riwayatByProduct(int $productId): array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, jenis, jumlah, keterangan, dibuat_pada
               FROM stock_movements
              WHERE product_id = ?
              ORDER BY id'
        );
        $stmt->execute([$productId]);

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

    /**
     * Total jumlah movement per jenis untuk satu produk.
     *
     * @return array{masuk: int, keluar: int}
     */
    public function totalsByJenis(int $productId): array
    {
        $stmt = $this->pdo->prepare(
            "SELECT COALESCE(SUM(CASE WHEN jenis = 'masuk' THEN jumlah END), 0)  AS total_masuk,
                    COALESCE(SUM(CASE WHEN jenis = 'keluar' THEN jumlah END), 0) AS total_keluar
               FROM stock_movements
              WHERE product_id = ?"
        );
        $stmt->execute([$productId]);
        $baris = $stmt->fetch(PDO::FETCH_ASSOC);

        return [
            'masuk' => (int) $baris['total_masuk'],
            'keluar' => (int) $baris['total_keluar'],
        ];
    }

    /**
     * Hitung stok seharusnya menurut buku besar: total masuk dikurangi keluar.
     *
     * Dibandingkan dengan products.stok_sekarang, method ini menjadi alat
     * rekonsiliasi: kedua nilai harus selalu sama pada database yang sehat.
     */
    public function stokMenurutLedger(int $productId): int
    {
        $stmt = $this->pdo->prepare(
            "SELECT COALESCE(SUM(CASE WHEN jenis = 'masuk' THEN jumlah ELSE -jumlah END), 0)
               FROM stock_movements
              WHERE product_id = ?"
        );
        $stmt->execute([$productId]);

        return (int) $stmt->fetchColumn();
    }

    /**
     * Ambil sku, nama, dan stok_sekarang satu produk; null bila tidak ada.
     *
     * @return array<string, mixed>|null
     */
    private function ambilProduk(int $productId): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT sku, nama, stok_sekarang FROM products WHERE id = ?'
        );
        $stmt->execute([$productId]);
        $baris = $stmt->fetch(PDO::FETCH_ASSOC);

        return $baris === false ? null : $baris;
    }
}