Dua proyek sebelumnya membangun database perpustakaan dan inventaris gudang. Proyek ketiga membawa dua tantangan yang belum kita hadapi sekaligus: uang dan waktu. Harga jual sebuah produk berubah dari bulan ke bulan, padahal pesanan lama harus tetap melaporkan angka yang benar; stok terbatas, padahal dua kasir bisa melayani pesanan besar pada saat bersamaan. Pada proyek ini kita membangun database penjualan ritel dari nol: katalog produk dengan dua harga (beli dan jual), data pelanggan, transaksi pesanan multi-item yang atomik, hingga analisis penjualan yang mengubah catatan transaksi menjadi keputusan bisnis.
Yang Akan Kita Bangun #
Toko Maju Jaya adalah toko ritel kecil yang melayani pembeli langsung dan pesanan pelanggan tetap. Kebutuhan hariannya kita tuangkan menjadi database dengan fitur berikut:
- Katalog produk dengan
kodeunik yang terbaca manusia, kategori,harga_beli,harga_jual, danstok— kolom stok dijagaCHECK (stok >= 0)sehingga database tidak pernah menyimpan stok negatif. - Data master pelanggan dengan email unik sebagai identitas kontak dan kota untuk segmentasi.
- Pesanan multi-item: tabel kepala
pesanan(siapa, kapan, status apa) dipasangkan dengan tabel detailpesanan_item(produk apa, berapa qty). - Pola snapshot harga: kolom
harga_saat_transaksimembekukan harga jual saat pesanan dibuat, sehingga perubahan harga di katalog tidak pernah merusak nilai pesanan lama;subtotaldihitung otomatis sebagai generated column. - Status pesanan yang tertutup: hanya
baru,diproses,selesai, danbatalyang diterima, dijagaCHECKdengan nilai awalbaru. - Transaksi atomik: kepala pesanan, seluruh item, dan pengurangan stok terjadi bersama-sama atau tidak sama sekali; percobaan menjual melebihi stok ditolak tepat saat transaksi berjalan.
- Analisis penjualan: omzet bulanan dengan
date_truncdangenerate_series, produk terlaris dengan dua ukuran peringkat, pelanggan terbaik, analisis keranjang belanja lewat self-join, dan margin laba kotor per produk. - Berkas uji dengan pola LULUS/GAGAL yang membuktikan setiap aturan bisnis ditegakkan oleh constraint, bukan oleh niat baik aplikasi.
Seluruh contoh memakai data deterministik dengan tanggal tetap sepanjang kuartal I 2026, sehingga setiap output yang Anda lihat dapat dicocokkan persis dengan hasil eksekusi Anda sendiri.
Skema Database #
Kebutuhan di atas diterjemahkan menjadi lima tabel. Diagram berikut memperlihatkan relasi antartabelnya:
Notasi ||--o{ dibaca “satu ke nol-atau-banyak”: satu kategori menampung banyak produk, satu pelanggan boleh berkali-kali memesan. Relasi yang paling penting justru relasi tak langsung antara pelanggan dan produk — keduanya hanya bertemu di pesanan_item, tabel detail yang menjadi bukti transaksi. Dua kolom di tabel inilah yang membedakan proyek ini dari dua proyek sebelumnya: harga_saat_transaksi merekam harga saat pesanan terjadi (harga di katalog boleh berubah setelahnya), dan subtotal dihitung PostgreSQL sendiri dari qty * harga_saat_transaksi sehingga tidak ada dua sumber kebenaran yang bisa saling bertentangan.
Prasyarat #
Proyek ini tidak memperkenalkan fitur baru; ia menyusun ulang materi yang sudah Anda pelajari. Pastikan Anda nyaman dengan chapter berikut:
- Perancangan: Relasi One-to-Many dan Normalisasi Database — dasar pemisahan kepala-detail pesanan.
- Table Design: Foreign Key , Check Constraint , dan Generated Columns — tiga constraint yang membentuk tulang punggung skema.
- Manipulasi Data: INSERT , RETURNING Clause , dan ON CONFLICT untuk alur kerja CRUD di Bagian 2.
- Joins: INNER JOIN dan Self Join — hampir semua laporan adalah join tiga-empat tabel, dan analisis keranjang memakai self-join.
- Transactions: BEGIN, COMMIT, dan ROLLBACK — inti transaksi penjualan di Bagian 3.
- Analisis: GROUP BY dan RANK dan DENSE RANK untuk laporan di Bagian 4.
Tidak ada extension atau tool tambahan yang dibutuhkan; cukup psql dan PostgreSQL 17 yang sudah terpasang sejak Instalasi PostgreSQL
.
Peta Bagian #
Proyek dikerjakan dalam empat bagian berurutan. Setiap bagian menghasilkan database yang benar-benar bekerja pada tahapnya:
| Bagian | Fokus |
|---|---|
| Bagian 1: Perancangan Database | Dari cerita kebutuhan ke lima tabel: constraint bisnis, pola snapshot harga, index, dan uji skema |
| Bagian 2: Pengelolaan Produk dan Pelanggan | CRUD data master: RETURNING, pencarian ILIKE, upsert dari sistem pusat, validasi masukan, dan aturan penghapusan |
| Bagian 3: Transaksi Penjualan | Pesanan multi-item atomik, penolakan overselling, bukti pemulihan, dan alur status pesanan |
| Bagian 4: Analisis Penjualan | Omzet bulanan, produk terlaris, pelanggan terbaik, analisis keranjang, dan margin laba kotor |
Setiap bagian mandiri: berkas SQL-nya membangun ulang tabel yang dibutuhkan di awal, sehingga Anda bisa masuk dari bagian mana pun tanpa membawa bekas eksekusi bagian sebelumnya.
Cara Mengikuti Proyek #
Kerjakan tiap bagian sambil mengetik ulang atau menyalin berkas SQL-nya, lalu jalankan dengan psql seperti pada chapter sebelumnya:
psql -X -f bagian/perancangan-database/buat-tabel.sql -d penjualanAturan mainnya sama dengan seluruh Course: setiap blok kode pada bagian-bagian berikut adalah berkas nyata yang dieksekusi sungguhan, dan blok Output yang Dihasilkan di bawahnya adalah hasil eksekusi tersebut, bukan tulisan tangan. Nilai-nilai kunci sengaja dipaku — tanggal pesanan memakai literal tetap seperti DATE '2026-04-05', harga ditulis dalam rupiah penuh — agar hasil Anda identik dengan hasil di halaman.
Selain berkas per bagian, halaman overview ini juga membawa artefak lengkap hasil akhir proyek yang dapat diunduh dari bagian Berkas Sumber di bawah halaman ini: database/skema.sql (skema final), database/isi-data.sql (data master dan seluruh riwayat pesanan kuartal I 2026), queries/ (lima query analisis siap pakai), dan tests/ (dua berkas uji LULUS/GAGAL). Berkas-berkas itu berguna setelah Anda menyelesaikan keempat bagian: terapkan kedua berkas database/ ke database kosong, jalankan berkas uji, lalu bandingkan hasilnya.
Skema adalah fondasi semuanya — dan keputusan termahal proyek ini, pola snapshot harga, diambil tepat di sana. Mari kita mulai dari Bagian 1.
Referensi #
- Tutorial SQL: membuat tabel
- Constraints pada tabel
- Tipe numeric untuk kolom uang
- Window functions untuk analisis
Rancang tabel produk, pelanggan, dan transaksi dengan detail order.
Buka → # Bagian 2: Pengelolaan Produk dan PelangganKelola data master produk dan pelanggan beserta kategorinya.
Buka → # Bagian 3: Transaksi PenjualanProses order multi-item dengan transaksi, cek stok, dan perhitungan total.
Buka → # Bagian 4: Analisis PenjualanAnalisis penjualan dengan agregasi, window function, dan laporan periodik.
Buka →Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
database/isi-data.sql
Unduh berkas-- isi-data.sql — data contoh lengkap: data master + riwayat dua belas
-- pesanan sepanjang kuartal I 2026 (29 item). Hasil akhirnya identik
-- dengan kondisi Bagian 4: stok sudah dikurangi penjualan kuartal.
-- Jalankan setelah database/skema.sql pada database kosong:
-- psql -X -f database/skema.sql -f database/isi-data.sql
-- Stok yang dimuat adalah stok akhir kuartal ditambah total terjual,
-- sehingga setelah pengurangan di akhir berkas papan stok berada pada
-- kondisi akhir kuartal. Pesanan 21 Februari memuat harga promo Kopi
-- 11500 sebagai contoh snapshot harga_saat_transaksi.
INSERT INTO kategori (nama) VALUES
('Sembako'),
('Minuman'),
('Makanan'),
('Kebersihan');
INSERT INTO produk (kode, nama, kategori_id, harga_beli, harga_jual, stok) VALUES
('PRD-001', 'Beras Premium 5kg', 1, 61000.00, 68000.00, 48),
('PRD-002', 'Gula Pasir 1kg', 1, 15000.00, 17000.00, 80),
('PRD-003', 'Minyak Goreng 2L', 1, 32000.00, 36500.00, 42),
('PRD-004', 'Telur Ayam 1kg', 1, 27000.00, 31000.00, 34),
('PRD-005', 'Kopi Sachet Instan', 2, 9000.00, 12000.00, 126),
('PRD-006', 'Teh Celup 50 kantong', 2, 11000.00, 13500.00, 47),
('PRD-007', 'Air Mineral 600ml', 2, 2500.00, 4000.00, 246),
('PRD-008', 'Mie Instan Goreng', 3, 2800.00, 3500.00, 170),
('PRD-009', 'Biskuit Cokelat', 3, 7500.00, 9500.00, 35),
('PRD-010', 'Sabun Cuci Piring 780ml', 4, 16000.00, 19500.00, 27);
INSERT INTO pelanggan (nama, email, kota) VALUES
('Budi Santoso', '[email protected]', 'Bandung'),
('Siti Rahayu', '[email protected]', 'Bandung'),
('Andi Wijaya', '[email protected]', 'Jakarta'),
('Dewi Lestari', '[email protected]', 'Surabaya'),
('Rudi Hartono', '[email protected]', 'Bandung'),
('Maya Putri', '[email protected]', 'Jakarta');
BEGIN;
INSERT INTO pesanan (pelanggan_id, tanggal, status) VALUES
(2, DATE '2026-01-05', 'selesai'),
(1, DATE '2026-01-12', 'selesai'),
(3, DATE '2026-01-25', 'selesai'),
(2, DATE '2026-02-03', 'selesai'),
(4, DATE '2026-02-08', 'selesai'),
(1, DATE '2026-02-15', 'selesai'),
(5, DATE '2026-02-21', 'selesai'),
(2, DATE '2026-03-02', 'selesai'),
(6, DATE '2026-03-09', 'selesai'),
(3, DATE '2026-03-16', 'selesai'),
(4, DATE '2026-03-23', 'selesai'),
(1, DATE '2026-03-30', 'selesai');
INSERT INTO pesanan_item (pesanan_id, produk_id, qty, harga_saat_transaksi)
SELECT p.id, pr.id, v.qty, v.harga
FROM (VALUES
('2026-01-05', 2, 'PRD-005', 10, 12000.00),
('2026-01-05', 2, 'PRD-002', 5, 17000.00),
('2026-01-12', 1, 'PRD-001', 2, 68000.00),
('2026-01-12', 1, 'PRD-003', 3, 36500.00),
('2026-01-25', 3, 'PRD-008', 20, 3500.00),
('2026-01-25', 3, 'PRD-007', 24, 4000.00),
('2026-02-03', 2, 'PRD-005', 12, 12000.00),
('2026-02-03', 2, 'PRD-002', 4, 17000.00),
('2026-02-03', 2, 'PRD-006', 2, 13500.00),
('2026-02-08', 4, 'PRD-010', 3, 19500.00),
('2026-02-08', 4, 'PRD-007', 12, 4000.00),
('2026-02-15', 1, 'PRD-001', 1, 68000.00),
('2026-02-15', 1, 'PRD-004', 5, 31000.00),
('2026-02-15', 1, 'PRD-008', 10, 3500.00),
('2026-02-21', 5, 'PRD-005', 8, 11500.00),
('2026-02-21', 5, 'PRD-007', 30, 4000.00),
('2026-03-02', 2, 'PRD-008', 25, 3500.00),
('2026-03-02', 2, 'PRD-007', 30, 4000.00),
('2026-03-09', 6, 'PRD-001', 3, 68000.00),
('2026-03-09', 6, 'PRD-002', 6, 17000.00),
('2026-03-09', 6, 'PRD-005', 6, 12000.00),
('2026-03-16', 3, 'PRD-008', 15, 3500.00),
('2026-03-16', 3, 'PRD-009', 5, 9500.00),
('2026-03-23', 4, 'PRD-003', 4, 36500.00),
('2026-03-23', 4, 'PRD-004', 4, 31000.00),
('2026-03-23', 4, 'PRD-010', 2, 19500.00),
('2026-03-30', 1, 'PRD-001', 2, 68000.00),
('2026-03-30', 1, 'PRD-005', 10, 12000.00),
('2026-03-30', 1, 'PRD-002', 5, 17000.00)
) AS v(tanggal, pelanggan_id, kode_produk, qty, harga)
JOIN pesanan p ON p.pelanggan_id = v.pelanggan_id
AND p.tanggal = v.tanggal::date
JOIN produk pr ON pr.kode = v.kode_produk;
-- kurangi stok seluruh produk yang terjual (pola set-based)
UPDATE produk pr
SET stok = pr.stok - d.qty_terjual
FROM (
SELECT produk_id, SUM(qty) AS qty_terjual
FROM pesanan_item
GROUP BY produk_id
) d
WHERE d.produk_id = pr.id;
COMMIT;
-- ringkasan kondisi akhir untuk pemeriksaan cepat
SELECT count(DISTINCT p.id) AS jumlah_pesanan,
count(i.id) AS jumlah_item,
sum(i.subtotal) AS total_omzet
FROM pesanan p
JOIN pesanan_item i ON i.pesanan_id = p.id;
database/skema.sql
Unduh berkas-- skema.sql — skema lengkap database penjualan ritel Toko Maju Jaya.
-- Terapkan pada database kosong:
-- psql -X -f database/skema.sql
-- Isi data contoh dengan database/isi-data.sql setelahnya.
-- Skema ini adalah hasil Bagian 1: lima tabel, constraint bisnis,
-- index jalur laporan, dan dokumentasi kolom lewat COMMENT ON.
CREATE TABLE kategori (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
nama text NOT NULL UNIQUE
);
CREATE TABLE produk (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
kode text NOT NULL UNIQUE,
nama text NOT NULL,
kategori_id integer NOT NULL REFERENCES kategori (id),
harga_beli numeric(12, 2) NOT NULL CHECK (harga_beli > 0),
harga_jual numeric(12, 2) NOT NULL CHECK (harga_jual > 0),
stok integer NOT NULL CHECK (stok >= 0)
);
CREATE TABLE pelanggan (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
nama text NOT NULL,
email text NOT NULL UNIQUE,
kota text NOT NULL
);
CREATE TABLE pesanan (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
pelanggan_id integer NOT NULL REFERENCES pelanggan (id),
tanggal date NOT NULL,
status text NOT NULL DEFAULT 'baru'
CHECK (status IN ('baru', 'diproses', 'selesai', 'batal'))
);
CREATE TABLE pesanan_item (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
pesanan_id integer NOT NULL REFERENCES pesanan (id) ON DELETE CASCADE,
produk_id integer NOT NULL REFERENCES produk (id),
qty integer NOT NULL CHECK (qty > 0),
harga_saat_transaksi numeric(12, 2) NOT NULL,
subtotal numeric(12, 2) GENERATED ALWAYS AS (qty * harga_saat_transaksi) STORED
);
CREATE INDEX idx_pesanan_tanggal ON pesanan (tanggal);
CREATE INDEX idx_pesanan_pelanggan ON pesanan (pelanggan_id);
CREATE INDEX idx_pesanan_item_pesanan ON pesanan_item (pesanan_id);
CREATE INDEX idx_pesanan_item_produk ON pesanan_item (produk_id);
COMMENT ON TABLE kategori IS 'Kelompok produk, mis. Sembako atau Minuman.';
COMMENT ON TABLE produk IS 'Katalog produk beserta harga beli, harga jual, dan stok.';
COMMENT ON COLUMN produk.kode IS 'Kode produk yang terbaca manusia pada label rak, mis. PRD-001.';
COMMENT ON COLUMN produk.harga_beli IS 'Harga modal terakhir dari pemasok.';
COMMENT ON COLUMN produk.stok IS 'Stok tersedia kini; CHECK (stok >= 0) menolak overselling.';
COMMENT ON TABLE pelanggan IS 'Pelanggan terdaftar Toko Maju Jaya.';
COMMENT ON TABLE pesanan IS 'Kepala transaksi: siapa memesan, kapan, dan statusnya.';
COMMENT ON COLUMN pesanan.status IS 'Salah satu dari baru/diproses/selesai/batal; awal baru.';
COMMENT ON TABLE pesanan_item IS 'Baris produk dalam satu pesanan.';
COMMENT ON COLUMN pesanan_item.harga_saat_transaksi IS 'Snapshot harga jual saat pesanan dibuat; tidak ikut berubah saat harga katalog dikoreksi.';
COMMENT ON COLUMN pesanan_item.subtotal IS 'Dihitung PostgreSQL: qty * harga_saat_transaksi.';
queries/analisis-keranjang.sql
Unduh berkas-- analisis-keranjang.sql — pasangan produk yang paling sering dibeli
-- bersama dalam satu pesanan (market basket analysis).
-- Triknya: SELF JOIN pesanan_item ke dirinya sendiri pada pesanan yang
-- sama, dengan syarat i1.produk_id < i2.produk_id agar tiap pasangan
-- hanya dihitung sekali dan produk tidak dipasangkan dengan dirinya.
-- Jalankan pada database hasil database/skema.sql + database/isi-data.sql.
SELECT p1.nama AS produk_a,
p2.nama AS produk_b,
count(DISTINCT i1.pesanan_id) AS sering_dibeli_bersama
FROM pesanan_item i1
JOIN pesanan_item i2
ON i1.pesanan_id = i2.pesanan_id
AND i1.produk_id < i2.produk_id
JOIN produk p1 ON p1.id = i1.produk_id
JOIN produk p2 ON p2.id = i2.produk_id
GROUP BY p1.nama, p2.nama
HAVING count(DISTINCT i1.pesanan_id) >= 2
ORDER BY sering_dibeli_bersama DESC, p1.nama, p2.nama;
queries/margin-laba-kotor.sql
Unduh berkas-- margin-laba-kotor.sql — laba kotor per produk: selisih harga jual saat
-- transaksi dengan harga beli. Pesanan promo ikut terhitung benar karena
-- margin memakai kolom snapshot harga_saat_transaksi, bukan harga jual
-- terkini di tabel produk.
-- Jalankan pada database hasil database/skema.sql + database/isi-data.sql.
SELECT pr.kode,
pr.nama,
k.nama AS kategori,
sum(i.qty) AS qty_terjual,
sum(i.subtotal) AS omzet,
sum((i.harga_saat_transaksi - pr.harga_beli) * i.qty) AS margin,
round(100.0
* sum((i.harga_saat_transaksi - pr.harga_beli) * i.qty)
/ sum(i.subtotal), 1) AS rasio_margin_pct
FROM pesanan_item i
JOIN produk pr ON pr.id = i.produk_id
JOIN kategori k ON k.id = pr.kategori_id
JOIN pesanan p ON p.id = i.pesanan_id AND p.status = 'selesai'
GROUP BY pr.id, pr.kode, pr.nama, k.nama
ORDER BY margin DESC, pr.kode;
queries/omzet-bulanan.sql
Unduh berkas-- omzet-bulanan.sql — omzet per bulan dengan date_trunc, rata-rata per
-- pesanan, omzet kumulatif dengan window function, dan laporan semester
-- yang menampilkan bulan tanpa penjualan (generate_series + LEFT JOIN).
-- Jalankan pada database hasil database/skema.sql + database/isi-data.sql.
WITH bulanan AS (
SELECT date_trunc('month', p.tanggal) AS bulan,
count(DISTINCT p.id) AS jumlah_pesanan,
sum(i.subtotal) AS omzet
FROM pesanan p
JOIN pesanan_item i ON i.pesanan_id = p.id
WHERE p.status = 'selesai'
GROUP BY 1
)
SELECT to_char(bulan, 'YYYY-MM') AS bulan,
jumlah_pesanan,
omzet,
round(omzet / jumlah_pesanan, 2) AS rata_per_pesanan,
sum(omzet) OVER (ORDER BY bulan) AS omzet_kumulatif
FROM bulanan
ORDER BY bulan;
-- Laporan semester: bulan tanpa penjualan tetap tampil dengan angka nol.
SELECT to_char(b.bulan, 'YYYY-MM') AS bulan,
count(DISTINCT p.id) AS jumlah_pesanan,
COALESCE(sum(i.subtotal), 0.00) AS omzet
FROM generate_series(DATE '2026-01-01', DATE '2026-06-01', interval '1 month') AS b(bulan)
LEFT JOIN pesanan p ON date_trunc('month', p.tanggal) = b.bulan
AND p.status = 'selesai'
LEFT JOIN pesanan_item i ON i.pesanan_id = p.id
GROUP BY b.bulan
ORDER BY b.bulan;
queries/pelanggan-terbaik.sql
Unduh berkas-- pelanggan-terbaik.sql — pelanggan dengan total belanja terbesar,
-- lengkap dengan jumlah pesanan dan peringkatnya.
-- Jalankan pada database hasil database/skema.sql + database/isi-data.sql.
SELECT pl.nama,
pl.kota,
count(DISTINCT p.id) AS jumlah_pesanan,
sum(i.subtotal) AS total_belanja,
rank() OVER (ORDER BY sum(i.subtotal) DESC) AS peringkat
FROM pelanggan pl
JOIN pesanan p ON p.pelanggan_id = pl.id AND p.status = 'selesai'
JOIN pesanan_item i ON i.pesanan_id = p.id
GROUP BY pl.id, pl.nama, pl.kota
ORDER BY peringkat, pl.nama;
queries/produk-terlaris.sql
Unduh berkas-- produk-terlaris.sql — peringkat produk dengan dua ukuran sekaligus:
-- jumlah unit terjual dan omzet. Dua kolom RANK memperlihatkan bahwa
-- peringkat bisa berbeda tergantung ukuran yang dipakai.
-- Jalankan pada database hasil database/skema.sql + database/isi-data.sql.
SELECT pr.kode,
pr.nama,
k.nama AS kategori,
sum(i.qty) AS qty_terjual,
sum(i.subtotal) AS omzet,
rank() OVER (ORDER BY sum(i.qty) DESC) AS peringkat_qty,
rank() OVER (ORDER BY sum(i.subtotal) DESC) AS peringkat_omzet
FROM pesanan_item i
JOIN produk pr ON pr.id = i.produk_id
JOIN kategori k ON k.id = pr.kategori_id
JOIN pesanan p ON p.id = i.pesanan_id AND p.status = 'selesai'
GROUP BY pr.id, pr.kode, pr.nama, k.nama
ORDER BY peringkat_omzet, pr.kode;
tests/uji-integritas.sql
Unduh berkas-- uji-integritas.sql — uji constraint bisnis pada database terisi.
-- Jalankan pada database yang memuat database/skema.sql dan database/isi-data.sql:
-- psql -X -f database/skema.sql -f database/isi-data.sql -f tests/uji-integritas.sql
-- Lulus bila setiap baris kolom status bernilai LULUS.
-- Kode produk uji memakai rentang PRD-9xx agar mudah dideteksi bila bocor.
CREATE TEMP TABLE hasil_uji (
nomor integer,
kasus text,
diharapkan text,
aktual text,
status text
);
DO $$
DECLARE
v_state text;
BEGIN
-- Kasus 1: email pelanggan duplikat (UNIQUE)
BEGIN
INSERT INTO pelanggan (nama, email, kota)
VALUES ('Budi Kembar', '[email protected]', 'Bandung');
INSERT INTO hasil_uji VALUES (1, 'email pelanggan duplikat', '23505', NULL, 'GAGAL: diterima');
EXCEPTION WHEN OTHERS THEN
GET STACKED DIAGNOSTICS v_state = RETURNED_SQLSTATE;
INSERT INTO hasil_uji VALUES (1, 'email pelanggan duplikat', '23505', v_state,
CASE WHEN v_state = '23505' THEN 'LULUS' ELSE 'GAGAL' END);
END;
-- Kasus 2: kode produk duplikat (UNIQUE)
BEGIN
INSERT INTO produk (kode, nama, kategori_id, harga_beli, harga_jual, stok)
VALUES ('PRD-001', 'Beras palsu', 1, 61000.00, 68000.00, 5);
INSERT INTO hasil_uji VALUES (2, 'kode produk duplikat', '23505', NULL, 'GAGAL: diterima');
EXCEPTION WHEN OTHERS THEN
GET STACKED DIAGNOSTICS v_state = RETURNED_SQLSTATE;
INSERT INTO hasil_uji VALUES (2, 'kode produk duplikat', '23505', v_state,
CASE WHEN v_state = '23505' THEN 'LULUS' ELSE 'GAGAL' END);
END;
-- Kasus 3: harga jual nol (CHECK)
BEGIN
INSERT INTO produk (kode, nama, kategori_id, harga_beli, harga_jual, stok)
VALUES ('PRD-901', 'Produk gratis', 3, 5000.00, 0, 5);
INSERT INTO hasil_uji VALUES (3, 'harga jual nol', '23514', NULL, 'GAGAL: diterima');
EXCEPTION WHEN OTHERS THEN
GET STACKED DIAGNOSTICS v_state = RETURNED_SQLSTATE;
INSERT INTO hasil_uji VALUES (3, 'harga jual nol', '23514', v_state,
CASE WHEN v_state = '23514' THEN 'LULUS' ELSE 'GAGAL' END);
END;
-- Kasus 4: stok produk negatif (CHECK, penjaga anti-overselling)
BEGIN
UPDATE produk SET stok = -1 WHERE kode = 'PRD-008';
INSERT INTO hasil_uji VALUES (4, 'stok produk negatif', '23514', NULL, 'GAGAL: diterima');
EXCEPTION WHEN OTHERS THEN
GET STACKED DIAGNOSTICS v_state = RETURNED_SQLSTATE;
INSERT INTO hasil_uji VALUES (4, 'stok produk negatif', '23514', v_state,
CASE WHEN v_state = '23514' THEN 'LULUS' ELSE 'GAGAL' END);
END;
-- Kasus 5: status pesanan tidak dikenal (CHECK)
BEGIN
INSERT INTO pesanan (pelanggan_id, tanggal, status)
VALUES (1, DATE '2026-04-01', 'menghilang');
INSERT INTO hasil_uji VALUES (5, 'status pesanan tidak dikenal', '23514', NULL, 'GAGAL: diterima');
EXCEPTION WHEN OTHERS THEN
GET STACKED DIAGNOSTICS v_state = RETURNED_SQLSTATE;
INSERT INTO hasil_uji VALUES (5, 'status pesanan tidak dikenal', '23514', v_state,
CASE WHEN v_state = '23514' THEN 'LULUS' ELSE 'GAGAL' END);
END;
-- Kasus 6: pesanan merujuk pelanggan yang tidak ada (FOREIGN KEY)
BEGIN
INSERT INTO pesanan (pelanggan_id, tanggal)
VALUES (999, DATE '2026-04-01');
INSERT INTO hasil_uji VALUES (6, 'pesanan merujuk pelanggan 999', '23503', NULL, 'GAGAL: diterima');
EXCEPTION WHEN OTHERS THEN
GET STACKED DIAGNOSTICS v_state = RETURNED_SQLSTATE;
INSERT INTO hasil_uji VALUES (6, 'pesanan merujuk pelanggan 999', '23503', v_state,
CASE WHEN v_state = '23503' THEN 'LULUS' ELSE 'GAGAL' END);
END;
-- Kasus 7: menghapus produk yang sudah pernah terjual (FOREIGN KEY)
BEGIN
DELETE FROM produk WHERE kode = 'PRD-005';
INSERT INTO hasil_uji VALUES (7, 'hapus produk yang sudah terjual', '23503', NULL, 'GAGAL: diterima');
EXCEPTION WHEN OTHERS THEN
GET STACKED DIAGNOSTICS v_state = RETURNED_SQLSTATE;
INSERT INTO hasil_uji VALUES (7, 'hapus produk yang sudah terjual', '23503', v_state,
CASE WHEN v_state = '23503' THEN 'LULUS' ELSE 'GAGAL' END);
END;
-- Kasus 8: qty item pesanan nol (CHECK)
BEGIN
INSERT INTO pesanan_item (pesanan_id, produk_id, qty, harga_saat_transaksi)
VALUES (1, 1, 0, 68000.00);
INSERT INTO hasil_uji VALUES (8, 'qty item pesanan nol', '23514', NULL, 'GAGAL: diterima');
EXCEPTION WHEN OTHERS THEN
GET STACKED DIAGNOSTICS v_state = RETURNED_SQLSTATE;
INSERT INTO hasil_uji VALUES (8, 'qty item pesanan nol', '23514', v_state,
CASE WHEN v_state = '23514' THEN 'LULUS' ELSE 'GAGAL' END);
END;
END $$;
SELECT nomor, kasus, diharapkan, aktual, status
FROM hasil_uji
ORDER BY nomor;
-- pastikan tidak ada baris uji yang bocor ke data asli
SELECT count(*) AS jumlah_produk, count(*) FILTER (WHERE kode LIKE 'PRD-9%') AS produk_uji_bocor
FROM produk;
tests/uji-transaksi.sql
Unduh berkas-- uji-transaksi.sql — uji angka-angka kunci kuartal I 2026 dan penjaga
-- anti-overselling. Jalankan pada database yang memuat database/skema.sql
-- dan database/isi-data.sql:
-- psql -X -f database/skema.sql -f database/isi-data.sql -f tests/uji-transaksi.sql
-- Lulus bila setiap baris kolom hasil bernilai LULUS.
CREATE TEMP TABLE hasil_uji (
nomor integer,
pengujian text,
hasil text
);
INSERT INTO hasil_uji
SELECT 1, 'jumlah pesanan kuartal = 12',
CASE WHEN count(*) = 12 THEN 'LULUS' ELSE 'GAGAL: aktual ' || count(*)::text END
FROM pesanan;
INSERT INTO hasil_uji
SELECT 2, 'jumlah baris item = 29',
CASE WHEN count(*) = 29 THEN 'LULUS' ELSE 'GAGAL: aktual ' || count(*)::text END
FROM pesanan_item;
INSERT INTO hasil_uji
SELECT 3, 'total omzet kuartal = 2767500.00',
CASE WHEN sum(i.subtotal) = 2767500.00
THEN 'LULUS' ELSE 'GAGAL: aktual ' || sum(i.subtotal)::text END
FROM pesanan_item i
JOIN pesanan p ON p.id = i.pesanan_id AND p.status = 'selesai';
INSERT INTO hasil_uji
SELECT 4, 'laba kotor kuartal = 523000.00',
CASE WHEN sum((i.harga_saat_transaksi - pr.harga_beli) * i.qty) = 523000.00
THEN 'LULUS' ELSE 'GAGAL: aktual berbeda' END
FROM pesanan_item i
JOIN produk pr ON pr.id = i.produk_id;
INSERT INTO hasil_uji
SELECT 5, 'stok akhir Air Mineral = 150',
CASE WHEN stok = 150 THEN 'LULUS' ELSE 'GAGAL: aktual ' || stok::text END
FROM produk WHERE kode = 'PRD-007';
INSERT INTO hasil_uji
SELECT 6, 'stok akhir Kopi Sachet = 80',
CASE WHEN stok = 80 THEN 'LULUS' ELSE 'GAGAL: aktual ' || stok::text END
FROM produk WHERE kode = 'PRD-005';
INSERT INTO hasil_uji
SELECT 7, 'snapshot harga promo 11500 untuk Kopi tersimpan',
CASE WHEN count(*) = 1 THEN 'LULUS' ELSE 'GAGAL: aktual ' || count(*)::text END
FROM pesanan_item i
JOIN produk pr ON pr.id = i.produk_id
WHERE pr.kode = 'PRD-005' AND i.harga_saat_transaksi = 11500.00;
-- Kasus 8: percobaan overselling ditolak CHECK (stok >= 0), dan seluruh
-- transaksinya digulung balik tanpa meninggalkan jejak.
DO $$
DECLARE
v_state text;
BEGIN
BEGIN
INSERT INTO pesanan (pelanggan_id, tanggal)
VALUES (1, DATE '2026-04-06');
INSERT INTO pesanan_item (pesanan_id, produk_id, qty, harga_saat_transaksi)
SELECT p.id, pr.id, 200, pr.harga_jual
FROM pesanan p
JOIN produk pr ON pr.kode = 'PRD-007'
WHERE p.tanggal = DATE '2026-04-06';
UPDATE produk pr
SET stok = pr.stok - i.qty
FROM pesanan_item i
WHERE i.pesanan_id = (SELECT id FROM pesanan WHERE tanggal = DATE '2026-04-06')
AND i.produk_id = pr.id;
INSERT INTO hasil_uji VALUES (8, 'overselling 200 dari stok 150 ditolak', 'GAGAL: diterima');
EXCEPTION WHEN OTHERS THEN
GET STACKED DIAGNOSTICS v_state = RETURNED_SQLSTATE;
INSERT INTO hasil_uji VALUES (8, 'overselling 200 dari stok 150 ditolak',
CASE WHEN v_state = '23514' THEN 'LULUS' ELSE 'GAGAL: SQLSTATE ' || v_state END);
END;
END $$;
INSERT INTO hasil_uji
SELECT 9, 'tidak ada jejak pesanan 6 April setelah penolakan',
CASE WHEN count(*) = 0 THEN 'LULUS' ELSE 'GAGAL: aktual ' || count(*)::text END
FROM pesanan WHERE tanggal = DATE '2026-04-06';
INSERT INTO hasil_uji
SELECT 10, 'stok Air Mineral tidak berubah oleh percobaan yang ditolak',
CASE WHEN stok = 150 THEN 'LULUS' ELSE 'GAGAL: aktual ' || stok::text END
FROM produk WHERE kode = 'PRD-007';
SELECT nomor, pengujian, hasil
FROM hasil_uji
ORDER BY nomor;