Schema dari Bagian 1 sudah berdiri; sekarang giliran kode PHP yang mengisinya dengan order pertama. Satu order pada dasarnya adalah dua bentuk data sekaligus: header (orders) yang mencatat siapa pemesannya dan statusnya, serta baris-baris item (order_items) yang mencatat apa saja yang dipesan. Pada bagian ini kita membangun OrderRepository yang menulis keduanya dalam satu transaksi, menyalin harga produk ke setiap baris item saat pesanan dibuat, dan selalu menghitung total ulang dari baris item — bukan menyimpannya sebagai kolom. Pola repository-nya sendiri sudah kita bahas pada chapter Repository Pattern
; di sini pola itu dipakai untuk kasus yang lebih menuntut: penulisan multi-tabel yang harus utuh atau batal bersama-sama.
Order sebagai Header dan Baris Item #
Mengapa order dipecah dua tabel, bukan satu kolom daftar barang? Karena setiap baris item adalah data tersendiri yang ingin dijumlahkan, diurutkan, dan dirujuk foreign key. Struktur yang kita pakai:
| Tabel | Isi | Peran |
|---|---|---|
orders | customer_id, status, created_at | siapa, di tahap apa, kapan |
order_items | order_id, product_id, qty, harga | apa, berapa, berapa harga saat pesan |
Perhatikan kolom harga pada order_items — ini bukan nilai yang di-JOIN dari products, melainkan salinan yang diambil pada saat order dibuat. Prinsip ini disebut snapshot harga, dan alasannya murni bisnis: harga produk berubah dari waktu ke waktu, tetapi kesepakatan yang sudah terjadi tidak boleh ikut berubah. Order bulan lalu yang mencatat Rp499.500,00 harus tetap menampilkan angka itu walau harga produk hari ini sudah turun. Karena snapshot sudah tersimpan per baris, kolom total di tabel orders tidak kita buat — total adalah nilai turunan yang selalu dihitung ulang dari SUM(qty * harga), sehingga tidak ada dua tempat yang bisa saling bertentangan.
Class OrderRepository #
Berikut isi lengkap OrderRepository.php sebagaimana tersedia pada berkas sumber src/OrderRepository.php di bagian bawah halaman:
<?php
declare(strict_types=1);
/**
* Lapisan akses data untuk order dan order_items.
*
* Semua query order dipusatkan di sini. Koneksi PDO disuntikkan lewat
* constructor; harga per item di-snapshot dari tabel products saat order
* dibuat sehingga perubahan harga produk di kemudian hari tidak pernah
* mengubah order lama.
*/
final class OrderRepository
{
public function __construct(private PDO $pdo)
{
// Pemeriksaan foreign key SQLite nonaktif secara default.
$this->pdo->exec('PRAGMA foreign_keys = ON');
}
/**
* Buat order baru berstatus pending beserta seluruh itemnya.
*
* Setiap elemen $items berupa array [productId, qty]. Harga disalin
* dari tabel products pada saat order dibuat (snapshot harga).
*
* Bila pemanggil sudah membuka transaksi, method ini bergabung ke
* dalam transaksi tersebut; bila belum, transaksi dibuka di sini.
*
* @param list<array{0: int, 1: int}> $items
*
* @return int id order yang baru dibuat
*/
public function buatOrder(int $customerId, array $items): int
{
if ($items === []) {
throw new InvalidArgumentException('Order harus memiliki minimal satu item.');
}
$transaksiLokal = !$this->pdo->inTransaction();
if ($transaksiLokal) {
$this->pdo->beginTransaction();
}
try {
$stmt = $this->pdo->prepare('SELECT id FROM customers WHERE id = ?');
$stmt->execute([$customerId]);
if ($stmt->fetchColumn() === false) {
throw new InvalidArgumentException("Customer #$customerId tidak ditemukan.");
}
$this->pdo->prepare(
"INSERT INTO orders (customer_id, status) VALUES (?, 'pending')"
)->execute([$customerId]);
$orderId = (int) $this->pdo->lastInsertId();
$stmt = $this->pdo->prepare(
'INSERT INTO order_items (order_id, product_id, qty, harga)
VALUES (?, ?, ?, ?)'
);
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.'
);
}
$harga = $this->hargaProduk($productId);
$stmt->execute([$orderId, $productId, $qty, $harga]);
}
if ($transaksiLokal) {
$this->pdo->commit();
}
return $orderId;
} catch (Throwable $e) {
if ($transaksiLokal && $this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
/**
* Ambil satu order beserta itemnya dan totalnya; null bila tidak ada.
*
* Total selalu dihitung ulang dari order_items, bukan disimpan —
* satu-satunya sumber kebenaran nilai order adalah baris itemnya.
*
* @return array<string, mixed>|null
*/
public function cariId(int $orderId): ?array
{
$stmt = $this->pdo->prepare(
'SELECT id, customer_id, status, created_at
FROM orders
WHERE id = ?'
);
$stmt->execute([$orderId]);
$order = $stmt->fetch(PDO::FETCH_ASSOC);
if ($order === false) {
return null;
}
$stmt = $this->pdo->prepare(
'SELECT oi.id, oi.product_id, p.sku, p.nama, oi.qty, oi.harga,
oi.qty * oi.harga AS subtotal
FROM order_items oi
JOIN products p ON p.id = oi.product_id
WHERE oi.order_id = ?
ORDER BY oi.id'
);
$stmt->execute([$orderId]);
$items = $stmt->fetchAll(PDO::FETCH_ASSOC);
$order['items'] = $items;
$order['total'] = array_sum(array_column($items, 'subtotal'));
return $order;
}
/**
* Daftar ringkasan order milik satu customer, urut id.
*
* @return list<array<string, mixed>>
*/
public function listByCustomer(int $customerId): array
{
$stmt = $this->pdo->prepare(
'SELECT o.id, o.status, o.created_at,
COUNT(oi.id) AS jumlah_item,
COALESCE(SUM(oi.qty * oi.harga), 0) AS total
FROM orders o
LEFT JOIN order_items oi ON oi.order_id = o.id
WHERE o.customer_id = ?
GROUP BY o.id
ORDER BY o.id'
);
$stmt->execute([$customerId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Baca harga terkini satu produk; lempar exception bila tidak ada.
*/
private function hargaProduk(int $productId): int
{
$stmt = $this->pdo->prepare('SELECT harga FROM products WHERE id = ?');
$stmt->execute([$productId]);
$harga = $stmt->fetchColumn();
if ($harga === false) {
throw new InvalidArgumentException("Produk #$productId tidak ditemukan.");
}
return (int) $harga;
}
}Beberapa keputusan pada class tersebut perlu dibedah satu per satu:
- Transaksi yang menghormati pemanggil —
$transaksiLokal = !$this->pdo->inTransaction()menentukan siapa pemilik transaksi. Dipakai sendirian seperti pada bagian ini,buatOrder()membuka dan meng-commit transaksinya sendiri. Namun bila pemanggil sudah membuka transaksi, method ini bergabung di dalamnya dan tidak ikut meng-commit. Perilaku ini belum terpakai hari ini, tetapi menjadi kunci pada Bagian 3 ketika order harus ditulis bersama pengurangan stok dalam satu transaksi yang lebih besar. - Snapshot harga — method privat
hargaProduk()membaca harga terkini tepat sebelum baris item ditulis; nilai itulah yang masukorder_items.harga, bukan referensi ke tabel produk. - Validasi berjalan sebelum dan selama penulisan — customer diperiksa lebih dulu dengan pesan yang jelas, lalu setiap item diperiksa bentuk dan keberadaan produknya. Constraint
CHECKdan foreign key pada schema tetap berdiri sebagai baris pertahanan terakhir bila pemeriksaan PHP ini terlewati. cariId()menghitung total — subtotal dihitung SQL (oi.qty * oi.harga) per baris, lalu totalnya dijumlahkan di PHP lewatarray_sum(array_column(...)). Order yang tidak ditemukan menghasilkannull, konsisten dengan kontrak?array.listByCustomer()memakaiLEFT JOIN+COALESCE— agregasiCOUNT/SUMper order dihitung database, danCOALESCE(..., 0)menjaga order tanpa item (secara teori tidak terjadi, tetapi query tidak boleh menggantung pada asumsi).
Pola Pembuka Setiap Contoh #
Seluruh contoh pada bagian ini dimulai dengan blok pembuka yang sama: buka koneksi :memory:, nyalakan error mode exception, aktifkan pragma foreign key, pasang schema Bagian 1, lalu ciptakan repository:
<?php
require __DIR__ . '/src/OrderRepository.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'));
$repo = new OrderRepository($pdo);sqlite::memory: menciptakan database baru di RAM yang musnah saat skrip selesai, sehingga setiap eksekusi selalu mulai dari enam produk dan lima pelanggan yang sama. ERRMODE_EXCEPTION mengubah galat SQL menjadi PDOException yang bisa ditangkap — tanpa opsi ini kegagalan query hanya mengembalikan false dan bug bersembunyi diam-diam. Detail ketiganya pernah dibahas pada chapter Koneksi PDO
dan Prepared Statements
.
Walkthrough Membuat dan Membaca Order #
Alur pertama: Siti (customer #2) memesan dua keyboard dan satu headset. Kita buat order-nya, baca kembali lewat cariId(), cetak setiap baris item dengan helper rupiah() dari Bagian 1, lalu buktikan id yang tidak dikenal menghasilkan null:
<?php
require __DIR__ . '/src/OrderRepository.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'));
$repo = new OrderRepository($pdo);
$orderId = $repo->buatOrder(2, [
[1, 2],
[3, 1],
]);
echo "Order #$orderId dibuat\n";
$order = $repo->cariId($orderId);
printf("Customer #%d, status: %s\n", $order['customer_id'], $order['status']);
foreach ($order['items'] as $item) {
printf(" %dx %s (%s) @ %s = %s\n", $item['qty'], $item['nama'], $item['sku'],
rupiah((int) $item['harga']), rupiah((int) $item['subtotal']));
}
echo "Total: ", rupiah((int) $order['total']), "\n";
var_dump($repo->cariId(999));
$terisi = $pdo->query("SELECT created_at IS NOT NULL FROM orders WHERE id = $orderId")->fetchColumn();
echo "created_at terisi otomatis: ", $terisi == 1 ? "ya" : "tidak", "\n";Output yang Dihasilkan:
Order #1 dibuat
Customer #2, status: pending
2x Keyboard Mekanik RGB (KB-100) @ Rp750.000,00 = Rp1.500.000,00
1x Headset Stereo Bluetooth (HD-300) @ Rp499.500,00 = Rp499.500,00
Total: Rp1.999.500,00
NULL
created_at terisi otomatis: yaItem dikirim sebagai daftar pasangan [productId, qty], dan hasil cariId() memuat kolom tampilan sku/nama hasil JOIN — nilai yang aman di-JOIN ulang karena bukan bagian dari kesepakatan harga. Total Rp1.999.500,00 adalah penjumlahan dua subtotal, dan karena jam server berubah-ubah, pemeriksaan created_at sengaja memakai IS NOT NULL alih-alih mencetak nilainya.
Snapshot Harga Saat Harga Produk Berubah #
Inilah ujian prinsip bagian ini. Andi (customer #3) memesan satu headset pada harga Rp499.500,00; keesokan harinya toko menurunkan harga menjadi Rp449.500,00. Order lama tidak boleh tersentuh, order baru harus memakai harga baru:
<?php
require __DIR__ . '/src/OrderRepository.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'));
$repo = new OrderRepository($pdo);
$idLama = $repo->buatOrder(3, [[3, 1]]);
$order = $repo->cariId($idLama);
$item = $order['items'][0];
printf("Order #%d: %d x %s @ %s (total %s)\n",
$idLama, $item['qty'], $item['nama'], rupiah((int) $item['harga']), rupiah((int) $order['total']));
$pdo->prepare('UPDATE products SET harga = ? WHERE id = ?')->execute([44950000, 3]);
echo "Harga produk diubah menjadi: ", rupiah(44950000), "\n\n";
$orderLama = $repo->cariId($idLama);
$itemLama = $orderLama['items'][0];
printf("Order lama #%d tetap : @ %s (total %s)\n",
$idLama, rupiah((int) $itemLama['harga']), rupiah((int) $orderLama['total']));
$idBaru = $repo->buatOrder(3, [[3, 1]]);
$orderBaru = $repo->cariId($idBaru);
$itemBaru = $orderBaru['items'][0];
printf("Order baru #%d pakai harga baru: @ %s (total %s)\n",
$idBaru, rupiah((int) $itemBaru['harga']), rupiah((int) $orderBaru['total']));
echo "\nDaftar order customer #3:\n";
foreach ($repo->listByCustomer(3) as $baris) {
printf(" #%d %8s %d item %s\n", $baris['id'], $baris['status'],
$baris['jumlah_item'], rupiah((int) $baris['total']));
}Output yang Dihasilkan:
Order #1: 1 x Headset Stereo Bluetooth @ Rp499.500,00 (total Rp499.500,00)
Harga produk diubah menjadi: Rp449.500,00
Order lama #1 tetap : @ Rp499.500,00 (total Rp499.500,00)
Order baru #2 pakai harga baru: @ Rp449.500,00 (total Rp449.500,00)
Daftar order customer #3:
#1 pending 1 item Rp499.500,00
#2 pending 1 item Rp449.500,00Output di atas adalah prinsip snapshot harga yang bekerja: order #1 tetap Rp499.500,00 setelah harga produk berubah, order #2 memakai harga baru, dan listByCustomer(3) menampilkan keduanya berdampingan dengan total masing-masing. Bila harga di-JOIN langsung dari products setiap kali dibaca, kedua order akan menampilkan harga yang sama dan riwayat transaksi berbohong.
Gagal di Tengah Jalan, Semua Batal #
Transaksi baru terasa berguna saat kegagalan datang di tengah penulisan. Header order ditulis lebih dulu; baris item ditulis satu per satu di dalam loop. Begitu satu item ternyata merujuk produk yang tidak ada, exception terjadi — dan seluruh transaksi, termasuk header yang sudah tertulis, dibatalkan:
<?php
require __DIR__ . '/src/OrderRepository.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'));
$repo = new OrderRepository($pdo);
$hitung = static fn (string $t): int => (int) $pdo->query("SELECT COUNT(*) FROM $t")->fetchColumn();
printf("Sebelum : order=%d item=%d\n", $hitung('orders'), $hitung('order_items'));
echo "\n[1] Item kedua merujuk produk yang tidak ada:\n";
try {
$repo->buatOrder(1, [[2, 1], [999, 1]]);
echo "Tersimpan.\n";
} catch (InvalidArgumentException $e) {
echo " Ditolak: {$e->getMessage()}\n";
}
echo "\n[2] qty tidak valid:\n";
try {
$repo->buatOrder(1, [[1, 0]]);
echo "Tersimpan.\n";
} catch (InvalidArgumentException $e) {
echo " Ditolak: {$e->getMessage()}\n";
}
echo "\n[3] Customer tidak dikenal:\n";
try {
$repo->buatOrder(77, [[1, 1]]);
echo "Tersimpan.\n";
} catch (InvalidArgumentException $e) {
echo " Ditolak: {$e->getMessage()}\n";
}
printf("\nSesudah: order=%d item=%d\n", $hitung('orders'), $hitung('order_items'));Output yang Dihasilkan:
Sebelum : order=0 item=0
[1] Item kedua merujuk produk yang tidak ada:
Ditolak: Produk #999 tidak ditemukan.
[2] qty tidak valid:
Ditolak: Setiap item harus berupa [productId, qty] dengan nilai positif.
[3] Customer tidak dikenal:
Ditolak: Customer #77 tidak ditemukan.
Sesudah: order=0 item=0Angka penutup order=0 item=0 adalah bukti atomiknya: pada kasus [1], header order #1 sempat tertulis di dalam transaksi sebelum exception terjadi, namun rollBack() menghapusnya bersama baris item pertama yang ikut tertulis. Tanpa transaksi, database akan menampung order tanpa item — data yatim yang menyelesaikan order apa. Materi lengkap mekanisme beginTransaction/commit/rollBack ada pada chapter Transactions
.
Cara Menjalankan Contoh #
Contoh pada bagian ini membutuhkan schema.sql dari Bagian 1 dan src/OrderRepository.php dari bagian ini. Susun foldernya sebagai berikut:
sistem-pemesanan/
├── bagian-1-model-pelanggan-dan-produk/
│ └── schema.sql
└── bagian-2-order-dan-order-items/
├── src/
│ └── OrderRepository.php
└── uji-order.phpSimpan salah satu walkthrough di atas sebagai uji-order.php di dalam folder bagian-2-order-dan-order-items, lalu jalankan:
php uji-order.phpPath __DIR__ . '/../bagian-1-model-pelanggan-dan-produk/schema.sql' menunjuk satu tingkat ke atas lalu masuk ke folder bagian saudara, sehingga struktur folder ini harus dipertahankan. Seluruh contoh memakai :memory:, jadi tidak ada berkas database yang tersisa setelah eksekusi.
Apa yang Berubah #
Dibandingkan Bagian 1 yang hanya memiliki schema, proyek kini memiliki lapisan akses data untuk order:
- Berkas
src/OrderRepository.phpbaru berisi class final dengan tiga operasi:buatOrder()yang menulis header dan item dalam satu transaksi,cariId()yang mengembalikan order beserta item dan total terhitung, sertalistByCustomer()untuk riwayat ringkas per pelanggan. - Prinsip snapshot harga terpasang:
order_items.hargamenyalin harga produk saat pesanan dibuat, dan total selalu dihitung ulang — terbukti pada walkthrough perubahan harga. - Atomikitas teruji: tiga kegagalan yang berbeda (produk hilang, qty tidak valid, customer tidak dikenal) semuanya meninggalkan database tanpa perubahan.
- Transaksi dirancang untuk bisa bergabung dengan pemanggilnya — kontrak yang akan dipakai OrderService pada Bagian 3.
Ringkasan #
OrderRepository menjadikan order sebagai satu kesatuan header dan baris item: penulisan keduanya atomik, harga di-snapshot per item sehingga sejarah transaksi tidak pernah ditulis ulang oleh perubahan harga, dan total dihitung dari satu-satunya sumber kebenaran — baris itemnya sendiri. Namun ada yang masih sengaja dibiarkan: buatOrder() tidak menyentuh stok sama sekali, sehingga barang bisa habis di rak tanpa satu pun order gagal. Bagian 3 menutup celah itu dengan OrderService yang menempatkan order dan pengurangan stok dalam satu transaksi yang sama, lengkap dengan mesin status order.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
src/OrderRepository.php
Unduh berkas<?php
declare(strict_types=1);
/**
* Lapisan akses data untuk order dan order_items.
*
* Semua query order dipusatkan di sini. Koneksi PDO disuntikkan lewat
* constructor; harga per item di-snapshot dari tabel products saat order
* dibuat sehingga perubahan harga produk di kemudian hari tidak pernah
* mengubah order lama.
*/
final class OrderRepository
{
public function __construct(private PDO $pdo)
{
// Pemeriksaan foreign key SQLite nonaktif secara default.
$this->pdo->exec('PRAGMA foreign_keys = ON');
}
/**
* Buat order baru berstatus pending beserta seluruh itemnya.
*
* Setiap elemen $items berupa array [productId, qty]. Harga disalin
* dari tabel products pada saat order dibuat (snapshot harga).
*
* Bila pemanggil sudah membuka transaksi, method ini bergabung ke
* dalam transaksi tersebut; bila belum, transaksi dibuka di sini.
*
* @param list<array{0: int, 1: int}> $items
*
* @return int id order yang baru dibuat
*/
public function buatOrder(int $customerId, array $items): int
{
if ($items === []) {
throw new InvalidArgumentException('Order harus memiliki minimal satu item.');
}
$transaksiLokal = !$this->pdo->inTransaction();
if ($transaksiLokal) {
$this->pdo->beginTransaction();
}
try {
$stmt = $this->pdo->prepare('SELECT id FROM customers WHERE id = ?');
$stmt->execute([$customerId]);
if ($stmt->fetchColumn() === false) {
throw new InvalidArgumentException("Customer #$customerId tidak ditemukan.");
}
$this->pdo->prepare(
"INSERT INTO orders (customer_id, status) VALUES (?, 'pending')"
)->execute([$customerId]);
$orderId = (int) $this->pdo->lastInsertId();
$stmt = $this->pdo->prepare(
'INSERT INTO order_items (order_id, product_id, qty, harga)
VALUES (?, ?, ?, ?)'
);
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.'
);
}
$harga = $this->hargaProduk($productId);
$stmt->execute([$orderId, $productId, $qty, $harga]);
}
if ($transaksiLokal) {
$this->pdo->commit();
}
return $orderId;
} catch (Throwable $e) {
if ($transaksiLokal && $this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
/**
* Ambil satu order beserta itemnya dan totalnya; null bila tidak ada.
*
* Total selalu dihitung ulang dari order_items, bukan disimpan —
* satu-satunya sumber kebenaran nilai order adalah baris itemnya.
*
* @return array<string, mixed>|null
*/
public function cariId(int $orderId): ?array
{
$stmt = $this->pdo->prepare(
'SELECT id, customer_id, status, created_at
FROM orders
WHERE id = ?'
);
$stmt->execute([$orderId]);
$order = $stmt->fetch(PDO::FETCH_ASSOC);
if ($order === false) {
return null;
}
$stmt = $this->pdo->prepare(
'SELECT oi.id, oi.product_id, p.sku, p.nama, oi.qty, oi.harga,
oi.qty * oi.harga AS subtotal
FROM order_items oi
JOIN products p ON p.id = oi.product_id
WHERE oi.order_id = ?
ORDER BY oi.id'
);
$stmt->execute([$orderId]);
$items = $stmt->fetchAll(PDO::FETCH_ASSOC);
$order['items'] = $items;
$order['total'] = array_sum(array_column($items, 'subtotal'));
return $order;
}
/**
* Daftar ringkasan order milik satu customer, urut id.
*
* @return list<array<string, mixed>>
*/
public function listByCustomer(int $customerId): array
{
$stmt = $this->pdo->prepare(
'SELECT o.id, o.status, o.created_at,
COUNT(oi.id) AS jumlah_item,
COALESCE(SUM(oi.qty * oi.harga), 0) AS total
FROM orders o
LEFT JOIN order_items oi ON oi.order_id = o.id
WHERE o.customer_id = ?
GROUP BY o.id
ORDER BY o.id'
);
$stmt->execute([$customerId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Baca harga terkini satu produk; lempar exception bila tidak ada.
*/
private function hargaProduk(int $productId): int
{
$stmt = $this->pdo->prepare('SELECT harga FROM products WHERE id = ?');
$stmt->execute([$productId]);
$harga = $stmt->fetchColumn();
if ($harga === false) {
throw new InvalidArgumentException("Produk #$productId tidak ditemukan.");
}
return (int) $harga;
}
}