Bagian 2 menutup dengan satu celah yang disengaja: buatOrder() tidak menyentuh stok, sehingga sepuluh orang bisa memesan barang yang tersisa tiga dan database tidak protes. Pada bagian ini celah itu kita tutup dengan OrderService — lapisan logika bisnis yang menempatkan order, pengurangan stok, dan pencatatan gerakan stok ke dalam satu transaksi: semuanya berhasil bersama, atau tidak ada yang berubah. Satu masalah lagi menyertainya: status order tidak boleh berubah secara bebas, karena order yang sudah dikirim tidak selayaknya “dibatalkan” begitu saja. Untuk itu kita membangun mesin transisi status kecil yang bisa diuji tanpa database.

Satu Transaksi untuk Order dan Stok #

Alur placeOrder() kita rancang sebagai empat fase dalam satu transaksi:

  1. Validasi — baca stok dan harga semua produk yang diminta, agregasi qty per produk (item [1, 2] dan [1, 3] untuk produk yang sama dihitung lima), dan pastikan stok mencukupi sebelum satu baris pun ditulis.
  2. Tulis order — panggil OrderRepository::buatOrder() dari Bagian 2. Header ditulis lebih dulu agar baris gerakan stok bisa merujuk order_id-nya; karena semuanya masih di dalam transaksi, urutan fisik penulisan bebas dipilih.
  3. Reservasi — kurangi stok tiap produk dengan UPDATE ... WHERE stok >= ? dan catat satu baris stock_movements jenis keluar.
  4. Commit — transaksi dikunci; kegagalan di fase mana pun memicu rollBack() dan tidak ada jejak tersisa.

Inilah saatnya fitur “transaksi yang menghormati pemanggil” dari Bagian 2 terpakai: placeOrder() membuka transaksinya, lalu buatOrder() mendeteksi transaksi aktif lewat inTransaction() dan bergabung di dalamnya alih-alih membuka transaksi kedua yang justru melempar PDOException. Satu syarat mutlak: service dan repository harus berbagi instance PDO yang sama — dua instance berbeda berarti dua koneksi, dan transaksi tidak bisa menyatukan keduanya.

Mesin Status Order #

Status order kita batasi pada lima nilai yang sudah dijaga CHECK constraint Bagian 1: pending, dibayar, dikirim, selesai, dan batal. Aturan bisnisnya kita nyatakan sebagai satu peta konstanta — status sekarang menuju daftar status tujuan yang diizinkan:

Status sekarangTujuan diizinkan
pendingdibayar, batal
dibayardikirim, batal
dikirimselesai
selesai— (terminal)
batal— (terminal)

Karena peta ini murni data, pemeriksaannya bisa diuji tanpa database sama sekali — sebuah state machine kecil yang hidup sebagai method statis:

php
<?php
require __DIR__ . '/src/OrderService.php';

$semua = ['pending', 'dibayar', 'dikirim', 'selesai', 'batal'];

printf("%-10s", 'dari \\ ke');
foreach ($semua as $ke) {
    printf("%-9s", $ke);
}
echo "\n";

foreach ($semua as $dari) {
    printf("%-10s", $dari);
    foreach ($semua as $ke) {
        printf("%-9s", OrderService::bolehTransisi($dari, $ke) ? 'ya' : '-');
    }
    echo "\n";
}

Output yang Dihasilkan:

text
dari \ ke pending  dibayar  dikirim  selesai  batal
pending   -        ya       -        -        ya
dibayar   -        -        ya       -        ya
dikirim   -        -        -        ya       -
selesai   -        -        -        -        -
batal     -        -        -        -        -

Matriks di atas adalah seluruh kebenaran mesin status dalam satu pandangan: baris pending hanya punya dua ya, selesai dan batal tidak punya satu pun, dan lompatan seperti pending -> selesai tertolak. Karena bolehTransisi() tidak menyentuh database, ia bisa diuji ratusan kali dalam satu detik pada Bagian 7 nanti tanpa perlu memasang schema.

Class OrderService #

Berikut isi lengkap src/OrderService.php, termasuk dua exception domain yang menyertainya:

php
<?php

declare(strict_types=1);

/**
 * Dilempar saat stok produk tidak mencukupi permintaan order.
 */
final class StokTidakCukupException extends RuntimeException
{
}

/**
 * Dilempar saat transisi status order tidak diizinkan oleh mesin status.
 */
final class TransisiTidakValidException extends RuntimeException
{
}

/**
 * Logika bisnis pemesanan: menempatkan order beserta reservasi stoknya
 * dalam satu transaksi, dan menjaga siklus hidup status order.
 */
final class OrderService
{
    /**
     * Peta transisi status yang diizinkan: status sekarang => status tujuan.
     *
     * selesai dan batal adalah status terminal (tidak punya tujuan).
     */
    private const TRANSISI = [
        'pending' => ['dibayar', 'batal'],
        'dibayar' => ['dikirim', 'batal'],
        'dikirim' => ['selesai'],
        'selesai' => [],
        'batal'   => [],
    ];

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

    /**
     * Cek apakah transisi status boleh dilakukan; murni dan mudah diuji.
     */
    public static function bolehTransisi(string $dari, string $ke): bool
    {
        return in_array($ke, self::TRANSISI[$dari] ?? [], true);
    }

    /**
     * Tempatkan order: validasi stok, tulis order + item, lalu reservasi
     * stok — semuanya dalam SATU transaksi. Kegagalan di titik mana pun
     * membatalkan seluruh perubahan.
     *
     * @param list<array{0: int, 1: int}> $items setiap elemen [productId, qty]
     *
     * @return int id order yang baru dibuat
     */
    public function placeOrder(int $customerId, array $items): int
    {
        if ($items === []) {
            throw new InvalidArgumentException('Order harus memiliki minimal satu item.');
        }

        $this->pdo->beginTransaction();
        try {
            $produk = $this->kumpulkanProduk($items);

            $orderId = $this->repository->buatOrder($customerId, $items);

            $stmtUpdate = $this->pdo->prepare(
                'UPDATE products SET stok = stok - ? WHERE id = ? AND stok >= ?'
            );
            $stmtMovement = $this->pdo->prepare(
                "INSERT INTO stock_movements (product_id, order_id, jenis, qty, keterangan)
                 VALUES (?, ?, 'keluar', ?, ?)"
            );
            foreach ($produk as $id => $info) {
                $stmtUpdate->execute([$info['dibutuhkan'], $id, $info['dibutuhkan']]);
                if ($stmtUpdate->rowCount() !== 1) {
                    throw new StokTidakCukupException(
                        sprintf('Stok %s (%s) berubah sebelum order ditulis.',
                            $info['nama'], $info['sku'])
                    );
                }
                $stmtMovement->execute([$id, $orderId, $info['dibutuhkan'], "order #$orderId"]);
            }

            $this->pdo->commit();

            return $orderId;
        } catch (Throwable $e) {
            if ($this->pdo->inTransaction()) {
                $this->pdo->rollBack();
            }
            throw $e;
        }
    }

    /**
     * Ubah status order setelah divalidasi mesin transisi.
     *
     * Mengembalikan false bila order tidak ditemukan; melempar
     * TransisiTidakValidException bila transisinya tidak diizinkan.
     */
    public function ubahStatus(int $orderId, string $statusBaru): bool
    {
        $stmt = $this->pdo->prepare('SELECT status FROM orders WHERE id = ?');
        $stmt->execute([$orderId]);
        $statusLama = $stmt->fetchColumn();
        if ($statusLama === false) {
            return false;
        }

        if (!self::bolehTransisi((string) $statusLama, $statusBaru)) {
            throw new TransisiTidakValidException(sprintf(
                'Transisi status %s -> %s tidak diizinkan untuk order #%d.',
                $statusLama, $statusBaru, $orderId
            ));
        }

        $this->pdo->prepare('UPDATE orders SET status = ? WHERE id = ?')
            ->execute([$statusBaru, $orderId]);

        return true;
    }

    /**
     * Baca data produk untuk semua item, agregasi qty per produk, dan
     * pastikan stok mencukupi SEBELUM satu baris pun ditulis.
     *
     * @param list<array{0: int, 1: int}> $items
     *
     * @return array<int, array{sku: string, nama: string, harga: int, stok: int, dibutuhkan: int}>
     */
    private function kumpulkanProduk(array $items): array
    {
        $diminta = [];
        foreach ($items as $item) {
            [$productId, $qty] = $item;
            if (!is_int($productId) || $productId < 1 || !is_int($qty) || $qty < 1) {
                throw new InvalidArgumentException(
                    'Setiap item harus berupa [productId, qty] dengan nilai positif.'
                );
            }
            $diminta[$productId] = ($diminta[$productId] ?? 0) + $qty;
        }

        $placeholder = implode(',', array_fill(0, count($diminta), '?'));
        $stmt = $this->pdo->prepare(
            "SELECT id, sku, nama, harga, stok FROM products WHERE id IN ($placeholder)"
        );
        $stmt->execute(array_keys($diminta));

        $produk = [];
        foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $baris) {
            $id = (int) $baris['id'];
            $produk[$id] = [
                'sku'        => $baris['sku'],
                'nama'       => $baris['nama'],
                'harga'      => (int) $baris['harga'],
                'stok'       => (int) $baris['stok'],
                'dibutuhkan' => $diminta[$id],
            ];
        }

        $kurang = [];
        foreach ($diminta as $id => $qty) {
            if (!isset($produk[$id])) {
                throw new InvalidArgumentException("Produk #$id tidak ditemukan.");
            }
            if ($produk[$id]['stok'] < $qty) {
                $info = $produk[$id];
                $kurang[] = sprintf('%s (%s): diminta %d, tersedia %d',
                    $info['nama'], $info['sku'], $qty, $info['stok']);
            }
        }
        if ($kurang !== []) {
            throw new StokTidakCukupException('Stok tidak cukup — ' . implode('; ', $kurang) . '.');
        }

        return $produk;
    }
}

Beberapa keputusan pada class tersebut perlu dibedah:

  1. Dua exception domain — StokTidakCukupException dan TransisiTidakValidException memisahkan “masalah bisnis” dari PDOException (masalah teknis). Pemanggil bisa menangkap keduanya secara terpisah tanpa menebak isi pesan. Pola exception khusus seperti ini dibahas tuntas pada chapter Custom Exceptions . Ketiganya dideklarasikan dalam satu berkas tanpa efek samping — sesuai aturan PSR-1 tentang berkas yang mendeklarasikan simbol baru.
  2. kumpulkanProduk() adalah gerbang validasi — satu query IN (...) membaca semua produk sekaligus, qty diagregasi per produk, dan seluruh kekurangan stok dilaporkan dalam satu exception (bukan berhenti di kekurangan pertama). Exception terjadi sebelum satu baris pun tertulis, sehingga rollBack() hampir tidak punya pekerjaan.
  3. UPDATE ... WHERE stok >= ? adalah penjaga kedua — pemeriksaan di kumpulkanProduk() dan pengurangan terjadi di dua perintah berbeda. Pada SQLite satu koneksi ini selalu konsisten, tetapi pola UPDATE bersyarat membuat pemeriksaan dan pengurangan atomik bahkan di database dengan banyak koneksi: bila stok berubah di antara keduanya, rowCount() menjadi 0 dan exception tetap terjadi.
  4. ubahStatus() memisahkan dua nasib — order yang tidak ditemukan menghasilkan false (bukan error), sedangkan transisi yang melanggar aturan melempar exception. Statusnya sendiri dijaga ulang oleh CHECK constraint di schema sebagai lapisan terakhir.

Walkthrough Order Berhasil #

Alur pertama: Siti memesan dua keyboard dan satu headset. Setelah placeOrder(), kita periksa stok sebelum dan sesudah, baca order lewat cariId(), lalu tampilkan gerakan stok yang ikut tercatat:

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

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

$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));

$stok = static fn (int $id): int => (int) $pdo->query("SELECT stok FROM products WHERE id = $id")->fetchColumn();
printf("Stok awal  : KB-100=%d, HD-300=%d\n", $stok(1), $stok(3));

$orderId = $service->placeOrder(2, [[1, 2], [3, 1]]);
$order = (new OrderRepository($pdo))->cariId($orderId);
printf("Order #%d (customer #%d, %s)\n", $order['id'], $order['customer_id'], $order['status']);
foreach ($order['items'] as $item) {
    printf("  %dx %s = %s\n", $item['qty'], $item['nama'], rupiah((int) $item['subtotal']));
}
echo "Total: ", rupiah((int) $order['total']), "\n";

printf("Stok akhir : KB-100=%d, HD-300=%d\n", $stok(1), $stok(3));

echo "Gerakan stok untuk order ini:\n";
foreach ($pdo->query('SELECT id, jenis, qty, keterangan FROM stock_movements WHERE order_id = 1 ORDER BY id') as $m) {
    printf("  #%d %s %d (%s)\n", $m['id'], $m['jenis'], $m['qty'], $m['keterangan']);
}

Output yang Dihasilkan:

text
Stok awal  : KB-100=12, HD-300=8
Order #1 (customer #2, pending)
  2x Keyboard Mekanik RGB = Rp1.500.000,00
  1x Headset Stereo Bluetooth = Rp499.500,00
Total: Rp1.999.500,00
Stok akhir : KB-100=10, HD-300=7
Gerakan stok untuk order ini:
  #7 keluar 2 (order #1)
  #8 keluar 1 (order #1)

Empat fakta terbaca sekaligus pada output di atas: stok keyboard turun dari 12 ke 10 dan headset dari 8 ke 7, order berdiri dengan status pending dan total Rp1.999.500,00, serta dua baris gerakan keluar tercatat merujuk order #1. Satu panggilan method menghasilkan lima perubahan (satu order, dua item, dua stok, dua gerakan) yang semuanya konsisten — itulah arti atomik.

Stok Tidak Cukup, Tidak Ada yang Berubah #

Sekarang skenario kegagalan: seseorang memesan sepuluh USB-C Hub padahal stoknya tiga. kumpulkanProduk() seharusnya melempar StokTidakCukupException sebelum menulis apa pun, dan seluruh tabel harus kembali dengan angka yang sama:

php
<?php
require __DIR__ . '/../bagian-2-order-dan-order-items/src/OrderRepository.php';
require __DIR__ . '/src/OrderService.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'));

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

$hitung = static fn (string $t): int => (int) $pdo->query("SELECT COUNT(*) FROM $t")->fetchColumn();
$stok = static fn (int $id): int => (int) $pdo->query("SELECT stok FROM products WHERE id = $id")->fetchColumn();

echo "Stok HB-500 sebelum: ", $stok(5), "\n";
echo "Mencoba memesan 10 unit...\n";
try {
    $service->placeOrder(1, [[5, 10]]);
    echo "Tersimpan.\n";
} catch (StokTidakCukupException $e) {
    echo "Ditolak: ", $e->getMessage(), "\n";
}

printf("Sesudah : stok HB-500=%d, order=%d, item=%d, gerakan=%d\n",
    $stok(5), $hitung('orders'), $hitung('order_items'), $hitung('stock_movements'));
echo "(gerakan=6 berarti hanya benih stok awal — tidak ada yang berubah)\n";

Output yang Dihasilkan:

text
Stok HB-500 sebelum: 3
Mencoba memesan 10 unit...
Ditolak: Stok tidak cukup — USB-C Hub 7-in-1 (HB-500): diminta 10, tersedia 3.
Sesudah : stok HB-500=3, order=0, item=0, gerakan=6
(gerakan=6 berarti hanya benih stok awal — tidak ada yang berubah)

Pesan exception menyebut produk, jumlah diminta, dan jumlah tersedia — informasi yang cukup untuk ditampilkan ke pengguna tanpa query tambahan. Baris penutupnya adalah buktinya: stok tetap 3, tidak ada order, tidak ada item, dan gerakan stok tetap enam baris benih dari Bagian 1. Bandingkan dengan versi tanpa transaksi: order akan tersimpan, stok bisa terlanjur dikurangi, dan kesalahan baru terlihat saat gudang gagal memenuhi pesanan.

Transisi Status Diterima dan Ditolak #

Walkthrough terakhir menyusuri siklus hidup status. Order #1 berjalan normal dari pending sampai selesai; order #2 menguji dua pelanggaran — lompatan langsung ke selesai dan pembatalan yang sah:

php
<?php
require __DIR__ . '/../bagian-2-order-dan-order-items/src/OrderRepository.php';
require __DIR__ . '/src/OrderService.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'));

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

$status = static fn (int $id): string => (string) $pdo->query("SELECT status FROM orders WHERE id = $id")->fetchColumn();
$coba = static function (int $id, string $ke) use ($service, $status): void {
    try {
        $service->ubahStatus($id, $ke);
        printf("  ubahStatus(#%d, '%s') -> OK (kini: %s)\n", $id, $ke, $status($id));
    } catch (TransisiTidakValidException $e) {
        printf("  ubahStatus(#%d, '%s') -> DITOLAK\n      %s\n", $id, $ke, $e->getMessage());
    }
};

echo "== Order #1: jalur normal ==\n";
$service->placeOrder(1, [[2, 1]]);
$coba(1, 'dibayar');
$coba(1, 'dikirim');
$coba(1, 'selesai');
$coba(1, 'batal');

echo "\n== Order #2: lompatan dan pembatalan ==\n";
$service->placeOrder(1, [[2, 1]]);
$coba(2, 'selesai');
$coba(2, 'batal');

$stok = (int) $pdo->query('SELECT stok FROM products WHERE id = 2')->fetchColumn();
echo "\nPerhatikan: order #2 dibatalkan, tetapi stok MS-200 tetap $stok\n";
echo "(pengembalian stok saat pembatalan dibangun di Bagian 4)\n";

Output yang Dihasilkan:

text
== Order #1: jalur normal ==
  ubahStatus(#1, 'dibayar') -> OK (kini: dibayar)
  ubahStatus(#1, 'dikirim') -> OK (kini: dikirim)
  ubahStatus(#1, 'selesai') -> OK (kini: selesai)
  ubahStatus(#1, 'batal') -> DITOLAK
      Transisi status selesai -> batal tidak diizinkan untuk order #1.

== Order #2: lompatan dan pembatalan ==
  ubahStatus(#2, 'selesai') -> DITOLAK
      Transisi status pending -> selesai tidak diizinkan untuk order #2.
  ubahStatus(#2, 'batal') -> OK (kini: batal)

Perhatikan: order #2 dibatalkan, tetapi stok MS-200 tetap 23
(pengembalian stok saat pembatalan dibangun di Bagian 4)

Empat transisi pertama diterima berurutan; selesai -> batal ditolak karena order yang sudah tuntas bukan milik mesin pembatalan lagi, dan pending -> selesai ditolak karena melewati pembayaran. Dua transisi terakhir menyingkap pekerjaan rumah yang tersisa.

Cara Menjalankan Contoh #

Contoh pada bagian ini membutuhkan berkas dari dua bagian sebelumnya. 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
    └── uji-order-service.php

Simpan salah satu walkthrough sebagai uji-order-service.php di dalam folder bagian-3-order-processing, lalu jalankan:

bash
php uji-order-service.php

Snippet matriks transisi hanya membutuhkan src/OrderService.php sehingga bisa dijalankan bahkan tanpa database. Sisanya memuat schema lewat path relatif dua tingkat: __DIR__ . '/../bagian-1-model-pelanggan-dan-produk/schema.sql' — struktur folder antarbagian ini harus dipertahankan.

Apa yang Berubah #

Dibandingkan Bagian 2 yang hanya menulis order, proyek kini memiliki lapisan logika bisnis:

  • Berkas src/OrderService.php baru berisi placeOrder() — order, item, pengurangan stok, dan gerakan stok dalam satu transaksi — plus ubahStatus() dan bolehTransisi() sebagai mesin status.
  • Dua exception domain baru: StokTidakCukupException dan TransisiTidakValidException, dipisahkan dari PDOException agar pemanggil menangkap masalah bisnis secara eksplisit.
  • Fitur transaksi-bergabung dari OrderRepository kini terpakai: repository menulis di dalam transaksi yang dibuka service.
  • Atomikitas terbukti dua arah: order berhasil membawa lima perubahan yang konsisten, dan order gagal tidak meninggalkan jejak sama sekali.
  • Status order kini dijaga mesin transisi yang bisa diuji tanpa database; pembatalan yang sah sudah bisa dilakukan, tetapi pengembalian stoknya masih menjadi tugas Bagian 4.

Ringkasan #

OrderService menyatukan yang sebelumnya terpisah: order dan stoknya bergerak dalam satu transaksi dengan validasi di depan, penjaga UPDATE bersyarat di tengah, dan gerakan stok sebagai jejak di belakang. Mesin status memberi order siklus hidup yang taat aturan — transisi liar ditolak dengan pesan yang jelas. Satu utang masih tercatat: pembatalan belum melepaskan stok. Bagian 4 melunasinya dengan InventoryService berjejak audit dan Validator yang menyaring masukan sebelum menyentuh transaksi.

Referensi #

Berkas Sumber

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

src/OrderService.php

Unduh berkas
php
<?php

declare(strict_types=1);

/**
 * Dilempar saat stok produk tidak mencukupi permintaan order.
 */
final class StokTidakCukupException extends RuntimeException
{
}

/**
 * Dilempar saat transisi status order tidak diizinkan oleh mesin status.
 */
final class TransisiTidakValidException extends RuntimeException
{
}

/**
 * Logika bisnis pemesanan: menempatkan order beserta reservasi stoknya
 * dalam satu transaksi, dan menjaga siklus hidup status order.
 */
final class OrderService
{
    /**
     * Peta transisi status yang diizinkan: status sekarang => status tujuan.
     *
     * selesai dan batal adalah status terminal (tidak punya tujuan).
     */
    private const TRANSISI = [
        'pending' => ['dibayar', 'batal'],
        'dibayar' => ['dikirim', 'batal'],
        'dikirim' => ['selesai'],
        'selesai' => [],
        'batal'   => [],
    ];

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

    /**
     * Cek apakah transisi status boleh dilakukan; murni dan mudah diuji.
     */
    public static function bolehTransisi(string $dari, string $ke): bool
    {
        return in_array($ke, self::TRANSISI[$dari] ?? [], true);
    }

    /**
     * Tempatkan order: validasi stok, tulis order + item, lalu reservasi
     * stok — semuanya dalam SATU transaksi. Kegagalan di titik mana pun
     * membatalkan seluruh perubahan.
     *
     * @param list<array{0: int, 1: int}> $items setiap elemen [productId, qty]
     *
     * @return int id order yang baru dibuat
     */
    public function placeOrder(int $customerId, array $items): int
    {
        if ($items === []) {
            throw new InvalidArgumentException('Order harus memiliki minimal satu item.');
        }

        $this->pdo->beginTransaction();
        try {
            $produk = $this->kumpulkanProduk($items);

            $orderId = $this->repository->buatOrder($customerId, $items);

            $stmtUpdate = $this->pdo->prepare(
                'UPDATE products SET stok = stok - ? WHERE id = ? AND stok >= ?'
            );
            $stmtMovement = $this->pdo->prepare(
                "INSERT INTO stock_movements (product_id, order_id, jenis, qty, keterangan)
                 VALUES (?, ?, 'keluar', ?, ?)"
            );
            foreach ($produk as $id => $info) {
                $stmtUpdate->execute([$info['dibutuhkan'], $id, $info['dibutuhkan']]);
                if ($stmtUpdate->rowCount() !== 1) {
                    throw new StokTidakCukupException(
                        sprintf('Stok %s (%s) berubah sebelum order ditulis.',
                            $info['nama'], $info['sku'])
                    );
                }
                $stmtMovement->execute([$id, $orderId, $info['dibutuhkan'], "order #$orderId"]);
            }

            $this->pdo->commit();

            return $orderId;
        } catch (Throwable $e) {
            if ($this->pdo->inTransaction()) {
                $this->pdo->rollBack();
            }
            throw $e;
        }
    }

    /**
     * Ubah status order setelah divalidasi mesin transisi.
     *
     * Mengembalikan false bila order tidak ditemukan; melempar
     * TransisiTidakValidException bila transisinya tidak diizinkan.
     */
    public function ubahStatus(int $orderId, string $statusBaru): bool
    {
        $stmt = $this->pdo->prepare('SELECT status FROM orders WHERE id = ?');
        $stmt->execute([$orderId]);
        $statusLama = $stmt->fetchColumn();
        if ($statusLama === false) {
            return false;
        }

        if (!self::bolehTransisi((string) $statusLama, $statusBaru)) {
            throw new TransisiTidakValidException(sprintf(
                'Transisi status %s -> %s tidak diizinkan untuk order #%d.',
                $statusLama, $statusBaru, $orderId
            ));
        }

        $this->pdo->prepare('UPDATE orders SET status = ? WHERE id = ?')
            ->execute([$statusBaru, $orderId]);

        return true;
    }

    /**
     * Baca data produk untuk semua item, agregasi qty per produk, dan
     * pastikan stok mencukupi SEBELUM satu baris pun ditulis.
     *
     * @param list<array{0: int, 1: int}> $items
     *
     * @return array<int, array{sku: string, nama: string, harga: int, stok: int, dibutuhkan: int}>
     */
    private function kumpulkanProduk(array $items): array
    {
        $diminta = [];
        foreach ($items as $item) {
            [$productId, $qty] = $item;
            if (!is_int($productId) || $productId < 1 || !is_int($qty) || $qty < 1) {
                throw new InvalidArgumentException(
                    'Setiap item harus berupa [productId, qty] dengan nilai positif.'
                );
            }
            $diminta[$productId] = ($diminta[$productId] ?? 0) + $qty;
        }

        $placeholder = implode(',', array_fill(0, count($diminta), '?'));
        $stmt = $this->pdo->prepare(
            "SELECT id, sku, nama, harga, stok FROM products WHERE id IN ($placeholder)"
        );
        $stmt->execute(array_keys($diminta));

        $produk = [];
        foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $baris) {
            $id = (int) $baris['id'];
            $produk[$id] = [
                'sku'        => $baris['sku'],
                'nama'       => $baris['nama'],
                'harga'      => (int) $baris['harga'],
                'stok'       => (int) $baris['stok'],
                'dibutuhkan' => $diminta[$id],
            ];
        }

        $kurang = [];
        foreach ($diminta as $id => $qty) {
            if (!isset($produk[$id])) {
                throw new InvalidArgumentException("Produk #$id tidak ditemukan.");
            }
            if ($produk[$id]['stok'] < $qty) {
                $info = $produk[$id];
                $kurang[] = sprintf('%s (%s): diminta %d, tersedia %d',
                    $info['nama'], $info['sku'], $qty, $info['stok']);
            }
        }
        if ($kurang !== []) {
            throw new StokTidakCukupException('Stok tidak cukup — ' . implode('; ', $kurang) . '.');
        }

        return $produk;
    }
}