Keputusan termahal pada database penjualan tidak diambil saat menulis laporan, melainkan saat merancang tabelnya. Salah menempatkan harga jual hanya di tabel produk, dan seluruh pesanan lama akan ikut berubah nilai setiap kali kasir mengoreksi harga — laporan bulan lalu tidak lagi bisa dipercaya. Pada bagian ini kita menerjemahkan cerita kebutuhan Toko Maju Jaya menjadi lima tabel: katalog produk dengan dua harga, data pelanggan, dan pasangan kepala-detail pesanan. Setiap aturan bisnis ditanam sebagai constraint, lalu kita buktikan satu per satu bahwa PostgreSQL benar-benar menegakkannya.

Dari Kebutuhan ke Lima Tabel #

Pemilik toko menceritakan kebutuhannya dengan sederhana: “saya jual barang, pelanggan memesan, saya catat siapa beli apa dan kapan, dan tiap akhir bulan saya ingin tahu yang laris dan yang untung.” Kalimat itu mengandung tiga entitas data — produk, pelanggan, dan transaksi — plus satu kebutuhan tersembunyi yang menentukan seluruh desain: harga produk berubah seiring waktu, tetapi nilai pesanan yang sudah lewat tidak boleh ikut berubah.

Normalisasi memecah masalah itu menjadi lima tabel. kategori dan produk adalah data master: apa yang dijual dan bagaimana dikelompokkan. pelanggan mencatat siapa yang membeli. Kebutuhan “siapa beli apa dan kapan” tidak muat dalam satu tabel: satu pesanan memuat beberapa produk sekaligus, dan kolom berulang semacam produk1, produk2, produk3 melanggar bentuk normal pertama sekaligus membatasi jumlah item per pesanan. Solusinya adalah pemisahan kepala-detail yang sudah dikenal dari chapter Relasi One-to-Many : tabel pesanan menyimpan informasi yang tunggal per transaksi (pelanggan, tanggal, status), sedangkan pesanan_item menyimpan baris-baris produk beserta qty-nya.

Dua kolom pada pesanan_item menjadi kunci desain proyek ini. Pertama, harga_saat_transaksi — salinan harga jual pada saat pesanan dibuat. Laporan margin di Bagian 4 memakai kolom ini, bukan harga_jual terkini, sehingga pesanan promo tiga bulan lalu tetap dihitung dengan harga promonya. Kedua, subtotal — kolom generated yang dihitung PostgreSQL sendiri dari qty * harga_saat_transaksi; aplikasi tidak bisa menuliskan nilai yang bertentangan dengan hitungannya.

Membuat Tabel #

sql buat-tabel.sql
-- buat-tabel.sql — struktur database penjualan ritel Toko Maju Jaya.
-- Lima tabel: kategori, produk, pelanggan, pesanan, dan pesanan_item.

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

Output yang Dihasilkan:

text
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE INDEX
CREATE INDEX
CREATE INDEX
CREATE INDEX

Mari kita bedah keputusan-keputusan pentingnya:

  1. numeric(12, 2) untuk semua kolom uang: harga beli, harga jual, harga saat transaksi, dan subtotal menyimpan rupiah dengan dua desimal. Tipe numeric dihitung secara eksak, tidak seperti float yang bisa kehilangan beberapa rupiah akibat pembulatan biner — selisih kecil yang menumpuk di laporan bulanan.
  2. CHECK (harga_beli > 0) dan CHECK (harga_jual > 0): produk gratis tidak ada dalam bisnis ini. Nilai nol dan negatif ditolak sebelum sempat tersimpan, sehingga laporan margin tidak pernah berhadapan dengan pembagian harga nol.
  3. CHECK (stok >= 0) pada produk: penjaga anti-overselling proyek ini. Stok negatif berarti menjual barang yang tidak pernah ada — dan karena pengurangan stok pesanan berlangsung lewat UPDATE, constraint inilah yang akan menggagalkan seluruh pesanan yang menuntut terlalu banyak, seperti dibuktikan di Bagian 3.
  4. status IN ('baru', 'diproses', 'selesai', 'batal') dengan DEFAULT 'baru': daftar status tertutup. Aplikasi tidak bisa menyisipkan status “menguap” atau “Selesai” (dengan huruf besar) — transisi status hanya boleh berjalan pada empat nilai itu, dan pesanan baru selalu lahir sebagai baru tanpa perlu dituliskan.
  5. harga_saat_transaksi tanpa CHECK, subtotal generated: snapshot harga boleh bernilai berapa pun yang sah saat transaksi — termasuk harga promo yang lebih rendah dari harga jual biasa — jadi kolom ini sengaja tidak dibatasi. Sebaliknya subtotal tidak pernah diisi manual: GENERATED ALWAYS AS (qty * harga_saat_transaksi) STORED membuat PostgreSQL menghitungnya dan menolak upaya penulisan langsung.
  6. ON DELETE CASCADE hanya pada pesanan_item → pesanan: menghapus kepala pesanan ikut membawa detailnya, karena item tanpa kepala tidak bermakna. Relasi pesanan_item → produk sengaja memakai perilaku bawaan yang melarang penghapusan produk yang sudah pernah dijual — riwayat penjualan adalah dokumen keuangan.
  7. Empat index di akhir berkas: laporan menyaring pesanan berdasarkan tanggal dan mengelompokkannya per pelanggan, sedangkan join laporan hampir selalu lewat pesanan_id atau produk_id pada tabel detail.

Data Master Toko #

Skema kosong belum bisa diuji, jadi kita isi data master yang akan menemani seluruh proyek: sepuluh produk dalam empat kategori (Sembako, Minuman, Makanan, Kebersihan) dengan rentang harga dan stok yang sengaja beragam, serta enam pelanggan dari tiga kota. Harga beli dan harga jual berpasangan di setiap baris — selisih keduanya adalah bibit margin yang dihitung di Bagian 4.

sql data-awal.sql
-- data-awal.sql — data master toko: kategori, produk, dan pelanggan.

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,  40),
    ('PRD-002', 'Gula Pasir 1kg',           1, 15000.00, 17000.00,  60),
    ('PRD-003', 'Minyak Goreng 2L',         1, 32000.00, 36500.00,  35),
    ('PRD-004', 'Telur Ayam 1kg',           1, 27000.00, 31000.00,  25),
    ('PRD-005', 'Kopi Sachet Instan',       2,  9000.00, 12000.00,  80),
    ('PRD-006', 'Teh Celup 50 kantong',     2, 11000.00, 13500.00,  45),
    ('PRD-007', 'Air Mineral 600ml',        2,  2500.00,  4000.00, 150),
    ('PRD-008', 'Mie Instan Goreng',        3,  2800.00,  3500.00, 100),
    ('PRD-009', 'Biskuit Cokelat',          3,  7500.00,  9500.00,  30),
    ('PRD-010', 'Sabun Cuci Piring 780ml',  4, 16000.00, 19500.00,  22);

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

SELECT p.id, p.kode, p.nama, k.nama AS kategori,
       p.harga_beli, p.harga_jual, p.stok
FROM produk p
JOIN kategori k ON k.id = p.kategori_id
ORDER BY p.id;

SELECT id, nama, email, kota
FROM pelanggan
ORDER BY id;

Output yang Dihasilkan:

text
INSERT 0 4
INSERT 0 10
INSERT 0 6
 id |  kode   |          nama           |  kategori  | harga_beli | harga_jual | stok 
----+---------+-------------------------+------------+------------+------------+------
  1 | PRD-001 | Beras Premium 5kg       | Sembako    |   61000.00 |   68000.00 |   40
  2 | PRD-002 | Gula Pasir 1kg          | Sembako    |   15000.00 |   17000.00 |   60
  3 | PRD-003 | Minyak Goreng 2L        | Sembako    |   32000.00 |   36500.00 |   35
  4 | PRD-004 | Telur Ayam 1kg          | Sembako    |   27000.00 |   31000.00 |   25
  5 | PRD-005 | Kopi Sachet Instan      | Minuman    |    9000.00 |   12000.00 |   80
  6 | PRD-006 | Teh Celup 50 kantong    | Minuman    |   11000.00 |   13500.00 |   45
  7 | PRD-007 | Air Mineral 600ml       | Minuman    |    2500.00 |    4000.00 |  150
  8 | PRD-008 | Mie Instan Goreng       | Makanan    |    2800.00 |    3500.00 |  100
  9 | PRD-009 | Biskuit Cokelat         | Makanan    |    7500.00 |    9500.00 |   30
 10 | PRD-010 | Sabun Cuci Piring 780ml | Kebersihan |   16000.00 |   19500.00 |   22
(10 rows)

 id |     nama     |      email       |   kota   
----+--------------+------------------+----------
  1 | Budi Santoso | [email protected] | Bandung
  2 | Siti Rahayu  | [email protected] | Bandung
  3 | Andi Wijaya  | [email protected] | Jakarta
  4 | Dewi Lestari | [email protected] | Surabaya
  5 | Rudi Hartono | [email protected] | Bandung
  6 | Maya Putri   | [email protected] | Jakarta
(6 rows)

Dua hal patut dicermati dari data ini. Pertama, kode produk seperti PRD-005 adalah identitas yang dibaca manusia pada label rak dan struk — berbeda dari id yang dikelola PostgreSQL, kode ini stabil dan mudah diketik kasir. Kedua, rentang margin sengaja lebar: Air Mineral dijual 4000 dari modal 2500 (margin 37,5 persen), sedangkan Beras Premium hanya 7000 dari modal 61000 (sekitar 10 persen). Keragaman ini akan menghasilkan laporan margin yang benar-benar membedakan produk di Bagian 4, bukan angka yang rata semua.

Menguji Constraint Sebelum Lanjut #

Skema yang belum diuji adalah skema yang belum selesai. Berkas berikut mencoba enam pelanggaran secara sengaja di dalam blok EXCEPTION, menangkap SQLSTATE yang dikembalikan PostgreSQL, lalu membandingkannya dengan yang diharapkan — pola yang sama dengan chapter Pengujian Integritas Data .

-- uji-constraint.sql — bukti bahwa constraint bisnis benar-benar ditegakkan.
-- Setiap pelanggaran ditangkap dalam blok EXCEPTION, lalu dibandingkan
-- dengan SQLSTATE yang diharapkan.

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 Kedua', '[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 tiruan', 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-100', '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 = -10 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', 'menguap');
        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;
END $$;

SELECT nomor, kasus, diharapkan, aktual, status
FROM hasil_uji
ORDER BY nomor;

Output yang Dihasilkan:

text
CREATE TABLE
DO
 nomor |             kasus             | diharapkan | aktual | status 
-------+-------------------------------+------------+--------+--------
     1 | email pelanggan duplikat      | 23505      | 23505  | LULUS
     2 | kode produk duplikat          | 23505      | 23505  | LULUS
     3 | harga jual nol                | 23514      | 23514  | LULUS
     4 | stok produk negatif           | 23514      | 23514  | LULUS
     5 | status pesanan tidak dikenal  | 23514      | 23514  | LULUS
     6 | pesanan merujuk pelanggan 999 | 23503      | 23503  | LULUS
(6 rows)

Enam kasus, enam vonis LULUS: email dan kode duplikat ditolak UNIQUE (SQLSTATE 23505), harga nol, stok negatif, dan status asing ditolak CHECK (23514), sedangkan pesanan untuk pelanggan yang tidak ada ditolak foreign key (23503). Kasus 4 layak digarisbawahi — penolakan terjadi pada UPDATE, bukan INSERT, yang berarti penjaga stok >= 0 aktif pada setiap jalur yang mencoba mengubah stok, termasuk jalur pengurangan stok saat pesanan diproses di Bagian 3.

Struktur database kini berdiri dan teruji. Katalog dan pelanggan siap diisi kegiatan nyata: produk baru masuk, harga dikoreksi, pelanggan mendaftar, dan sistem pusat mengirim pembaruan katalog. Bagian Pengelolaan Produk dan Pelanggan menghidupkan data master itu sehari penuh di kehidupan toko.

Referensi #

Berkas Sumber

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

buat-tabel.sql

Unduh berkas
sql
-- buat-tabel.sql — struktur database penjualan ritel Toko Maju Jaya.
-- Lima tabel: kategori, produk, pelanggan, pesanan, dan pesanan_item.

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

data-awal.sql

Unduh berkas
sql
-- data-awal.sql — data master toko: kategori, produk, dan pelanggan.

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,  40),
    ('PRD-002', 'Gula Pasir 1kg',           1, 15000.00, 17000.00,  60),
    ('PRD-003', 'Minyak Goreng 2L',         1, 32000.00, 36500.00,  35),
    ('PRD-004', 'Telur Ayam 1kg',           1, 27000.00, 31000.00,  25),
    ('PRD-005', 'Kopi Sachet Instan',       2,  9000.00, 12000.00,  80),
    ('PRD-006', 'Teh Celup 50 kantong',     2, 11000.00, 13500.00,  45),
    ('PRD-007', 'Air Mineral 600ml',        2,  2500.00,  4000.00, 150),
    ('PRD-008', 'Mie Instan Goreng',        3,  2800.00,  3500.00, 100),
    ('PRD-009', 'Biskuit Cokelat',          3,  7500.00,  9500.00,  30),
    ('PRD-010', 'Sabun Cuci Piring 780ml',  4, 16000.00, 19500.00,  22);

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

SELECT p.id, p.kode, p.nama, k.nama AS kategori,
       p.harga_beli, p.harga_jual, p.stok
FROM produk p
JOIN kategori k ON k.id = p.kategori_id
ORDER BY p.id;

SELECT id, nama, email, kota
FROM pelanggan
ORDER BY id;

uji-constraint.sql

Unduh berkas
sql
-- uji-constraint.sql — bukti bahwa constraint bisnis benar-benar ditegakkan.
-- Setiap pelanggaran ditangkap dalam blok EXCEPTION, lalu dibandingkan
-- dengan SQLSTATE yang diharapkan.

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 Kedua', '[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 tiruan', 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-100', '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 = -10 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', 'menguap');
        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;
END $$;

SELECT nomor, kasus, diharapkan, aktual, status
FROM hasil_uji
ORDER BY nomor;