Keputusan termahal pada sebuah database tidak diambil saat menulis query, melainkan saat merancang tabelnya. Salah menilai relasi barang–lokasi sebagai satu kolom sederhana, dan gudang yang sama akan kesulitan ketika satu barang harus tersimpan di dua rak sekaligus. Pada bagian ini kita merancang seluruh struktur database inventaris gudang: lima tabel, constraint bisnis yang menolak data mustahil, dan index untuk jalur query yang paling sering dipakai — lalu membuktikan bahwa setiap aturan benar-benar ditegakkan oleh PostgreSQL, bukan hanya oleh niat baik aplikasi.

Lima Tabel Inti #

Gudang pada proyek ini milik CV Maju Jaya, distributor barang kebutuhan harian dengan dua gedung: Gudang Utama dan Gudang Cadangan. Kebutuhan datanya menuntut lima tabel. Tabel barang dan lokasi adalah data master: apa yang disimpan dan di mana. Tabel stok_lokasi menjadi jembatan keduanya — pola junction table — karena satu barang dapat berada di banyak lokasi dan satu lokasi menampung banyak barang. Riwayat pergerakan dicatat pada pasangan mutasi (kepala dokumen: tipe, tanggal, petugas) dan mutasi_item (baris detail: barang, jumlah, dari lokasi mana ke lokasi mana).

sql buat-tabel.sql
-- buat-tabel.sql — struktur database inventaris gudang.
-- Lima tabel: barang, lokasi, stok_lokasi, mutasi, dan mutasi_item.

CREATE TABLE barang (
    id           integer          GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    kode         text             NOT NULL UNIQUE,
    nama         text             NOT NULL,
    kategori     text             NOT NULL,
    satuan       text             NOT NULL,
    stok_minimum integer          NOT NULL CHECK (stok_minimum >= 0),
    harga_beli   numeric(12, 2)   NOT NULL CHECK (harga_beli > 0)
);

CREATE TABLE lokasi (
    id     integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    kode   text    NOT NULL UNIQUE,
    gudang text    NOT NULL,
    rak    text    NOT NULL,
    UNIQUE (gudang, rak)
);

CREATE TABLE stok_lokasi (
    barang_id integer NOT NULL REFERENCES barang (id) ON DELETE CASCADE,
    lokasi_id integer NOT NULL REFERENCES lokasi (id),
    qty       integer NOT NULL CONSTRAINT stok_qty_nonneg CHECK (qty >= 0),
    PRIMARY KEY (barang_id, lokasi_id)
);

CREATE TABLE mutasi (
    id         integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    kode       text    NOT NULL UNIQUE,
    tipe       text    NOT NULL CHECK (tipe IN ('masuk', 'keluar', 'pindah')),
    tanggal    date    NOT NULL,
    petugas    text    NOT NULL,
    keterangan text
);

CREATE TABLE mutasi_item (
    id               integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    mutasi_id        integer NOT NULL REFERENCES mutasi (id) ON DELETE CASCADE,
    barang_id        integer NOT NULL REFERENCES barang (id),
    qty              integer NOT NULL CHECK (qty > 0),
    lokasi_asal_id   integer REFERENCES lokasi (id),
    lokasi_tujuan_id integer REFERENCES lokasi (id),
    CONSTRAINT arah_lokasi_terisi
        CHECK (num_nonnulls(lokasi_asal_id, lokasi_tujuan_id) >= 1)
);

CREATE INDEX idx_mutasi_tanggal       ON mutasi (tanggal);
CREATE INDEX idx_mutasi_item_barang   ON mutasi_item (barang_id);
CREATE INDEX idx_mutasi_item_mutasi   ON mutasi_item (mutasi_id);

Output yang Dihasilkan:

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

Mari kita bedah keputusan-keputusan pentingnya:

  1. GENERATED ALWAYS AS IDENTITY PRIMARY KEY: id diisi otomatis oleh PostgreSQL dan dijamin unik. Berbeda dengan serial, identity column mengikuti standar SQL dan tidak dapat diisi sembarangan tanpa perintah OVERRIDING SYSTEM VALUE — perlindungan kecil yang menyelamatkan konsistensi id.
  2. kode text NOT NULL UNIQUE: kode barang seperti BRG-007 adalah identitas yang dibaca manusia di label fisik dan surat jalan. Kolom ini dijamin unik sehingga dua barang berbeda tidak akan pernah tertukar labelnya.
  3. stok_minimum dan harga_beli: dua angka bisnis yang dipakai laporan pada Bagian 4. CHECK pada keduanya menolak nilai negatif — stok minimum negatif dan harga beli nol sama-sama tidak bermakna.
  4. PRIMARY KEY (barang_id, lokasi_id) di stok_lokasi: kunci primer gabungan sekaligus menjadi target ON CONFLICT saat stok ditambah. Satu pasangan barang–lokasi hanya boleh punya satu baris stok; itulah yang menjaga tabel tetap rapi.
  5. CONSTRAINT stok_qty_nonneg CHECK (qty >= 0): penjaga gerbang terpenting proyek ini. Stok negatif berarti barang keluar tanpa pernah masuk — data yang mustahil secara fisik, dan database menolaknya sebelum sempat tersimpan.
  6. num_nonnulls(lokasi_asal_id, lokasi_tujuan_id) >= 1: satu baris detail mutasi wajib menyebut minimal satu lokasi. Barang masuk cukup menulis lokasi tujuan, barang keluar cukup lokasi asal, dan perpindahan menulis keduanya.
  7. Tiga index di akhir berkas: query laporan sering menyaring mutasi berdasarkan tanggal, dan detail mutasi sering di-join lewat barang_id maupun mutasi_id. Index pada tiga kolom itu mempercepat jalur-jalur tersebut.

Data Master #

Skema kosong belum bisa diuji dengan nyaman, jadi kita isi dua tabel master dengan data yang akan menemani seluruh proyek: sembilan barang dalam tiga kategori (Alat Tulis, Rumah Tangga, Pangan) dengan satuan yang beragam — box, lusin, pcs, dus, karung — serta lima lokasi: tiga rak di Gudang Utama dan dua rak di Gudang Cadangan.

sql data-awal.sql
-- data-awal.sql — data master: daftar barang dan lokasi penyimpanan.

INSERT INTO barang (kode, nama, kategori, satuan, stok_minimum, harga_beli) VALUES
    ('BRG-001', 'Kertas HVS A4 80gsm',   'Alat Tulis',  'box',    10, 185000.00),
    ('BRG-002', 'Pulpen Gel Hitam',      'Alat Tulis',  'box',    20,  42000.00),
    ('BRG-003', 'Buku Tulis 58 halaman', 'Alat Tulis',  'lusin',  30,  38500.00),
    ('BRG-004', 'Tinta Printer Hitam',   'Alat Tulis',  'pcs',     8,  95000.00),
    ('BRG-005', 'Sabun Cuci Cair 1L',    'Rumah Tangga','dus',    12,  87000.00),
    ('BRG-006', 'Deterjen Bubuk 800g',   'Rumah Tangga','dus',    15,  72000.00),
    ('BRG-007', 'Minyak Goreng 2L',      'Pangan',      'dus',    25, 115000.00),
    ('BRG-008', 'Beras Premium 5kg',     'Pangan',      'karung', 10, 320000.00),
    ('BRG-009', 'Kopi Sachet Instan',    'Pangan',      'dus',    40,  96000.00);

INSERT INTO lokasi (kode, gudang, rak) VALUES
    ('G1-A-01', 'Gudang Utama',     'Rak A-01'),
    ('G1-B-02', 'Gudang Utama',     'Rak B-02'),
    ('G1-C-03', 'Gudang Utama',     'Rak C-03'),
    ('G2-A-01', 'Gudang Cadangan',  'Rak A-01'),
    ('G2-B-02', 'Gudang Cadangan',  'Rak B-02');

SELECT id, kode, nama, satuan, stok_minimum, harga_beli
FROM barang
ORDER BY id;

SELECT id, kode, gudang, rak
FROM lokasi
ORDER BY id;

Output yang Dihasilkan:

text
INSERT 0 9
INSERT 0 5
 id |  kode   |         nama          | satuan | stok_minimum | harga_beli 
----+---------+-----------------------+--------+--------------+------------
  1 | BRG-001 | Kertas HVS A4 80gsm   | box    |           10 |  185000.00
  2 | BRG-002 | Pulpen Gel Hitam      | box    |           20 |   42000.00
  3 | BRG-003 | Buku Tulis 58 halaman | lusin  |           30 |   38500.00
  4 | BRG-004 | Tinta Printer Hitam   | pcs    |            8 |   95000.00
  5 | BRG-005 | Sabun Cuci Cair 1L    | dus    |           12 |   87000.00
  6 | BRG-006 | Deterjen Bubuk 800g   | dus    |           15 |   72000.00
  7 | BRG-007 | Minyak Goreng 2L      | dus    |           25 |  115000.00
  8 | BRG-008 | Beras Premium 5kg     | karung |           10 |  320000.00
  9 | BRG-009 | Kopi Sachet Instan    | dus    |           40 |   96000.00
(9 rows)

 id |  kode   |     gudang      |   rak    
----+---------+-----------------+----------
  1 | G1-A-01 | Gudang Utama    | Rak A-01
  2 | G1-B-02 | Gudang Utama    | Rak B-02
  3 | G1-C-03 | Gudang Utama    | Rak C-03
  4 | G2-A-01 | Gudang Cadangan | Rak A-01
  5 | G2-B-02 | Gudang Cadangan | Rak B-02
(5 rows)

Dua hal perlu dicermati dari data ini. Pertama, harga_beli bertipe numeric(12, 2) dan ditulis dengan dua desimal (185000.00) — uang disimpan sebagai numeric, bukan float, karena pembulatan pecahan biner bisa membuat laporan nilai persediaan meleset beberapa rupiah yang lama-lama menumpuk. Kedua, stok_minimum sengaja bervariasi (8 sampai 40): pada Bagian 4 kita akan melihat beberapa barang jatuh di bawah ambangnya dan beberapa lagi justru menumpuk tak tersentuh.

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
    -- satu mutasi sah sebagai induk untuk kasus yang mengujinya
    INSERT INTO mutasi (kode, tipe, tanggal, petugas, keterangan)
    VALUES ('MUT-UJI-1', 'masuk', DATE '2026-02-01', 'Budi', 'mutasi untuk pengujian');

    -- Kasus 1: kode barang duplikat (UNIQUE)
    BEGIN
        INSERT INTO barang (kode, nama, kategori, satuan, stok_minimum, harga_beli)
        VALUES ('BRG-001', 'Kertas duplikat', 'Alat Tulis', 'box', 5, 1000.00);
        INSERT INTO hasil_uji VALUES (1, 'kode barang duplikat', '23505', NULL, 'GAGAL: diterima');
    EXCEPTION WHEN OTHERS THEN
        GET STACKED DIAGNOSTICS v_state = RETURNED_SQLSTATE;
        INSERT INTO hasil_uji VALUES (1, 'kode barang duplikat', '23505', v_state,
            CASE WHEN v_state = '23505' THEN 'LULUS' ELSE 'GAGAL' END);
    END;

    -- Kasus 2: stok_minimum negatif (CHECK)
    BEGIN
        INSERT INTO barang (kode, nama, kategori, satuan, stok_minimum, harga_beli)
        VALUES ('BRG-100', 'Barang rusak', 'Pangan', 'pcs', -5, 1000.00);
        INSERT INTO hasil_uji VALUES (2, 'stok_minimum negatif', '23514', NULL, 'GAGAL: diterima');
    EXCEPTION WHEN OTHERS THEN
        GET STACKED DIAGNOSTICS v_state = RETURNED_SQLSTATE;
        INSERT INTO hasil_uji VALUES (2, 'stok_minimum negatif', '23514', v_state,
            CASE WHEN v_state = '23514' THEN 'LULUS' ELSE 'GAGAL' END);
    END;

    -- Kasus 3: tipe mutasi tidak dikenal (CHECK)
    BEGIN
        INSERT INTO mutasi (kode, tipe, tanggal, petugas)
        VALUES ('MUT-UJI-2', 'hilang', DATE '2026-02-01', 'Budi');
        INSERT INTO hasil_uji VALUES (3, 'tipe mutasi tidak dikenal', '23514', NULL, 'GAGAL: diterima');
    EXCEPTION WHEN OTHERS THEN
        GET STACKED DIAGNOSTICS v_state = RETURNED_SQLSTATE;
        INSERT INTO hasil_uji VALUES (3, 'tipe mutasi tidak dikenal', '23514', v_state,
            CASE WHEN v_state = '23514' THEN 'LULUS' ELSE 'GAGAL' END);
    END;

    -- Kasus 4: item mutasi tanpa lokasi asal maupun tujuan (CHECK arah_lokasi_terisi)
    BEGIN
        INSERT INTO mutasi_item (mutasi_id, barang_id, qty)
        VALUES ((SELECT id FROM mutasi WHERE kode = 'MUT-UJI-1'),
                (SELECT id FROM barang WHERE kode = 'BRG-001'),
                10);
        INSERT INTO hasil_uji VALUES (4, 'item tanpa lokasi asal/tujuan', '23514', NULL, 'GAGAL: diterima');
    EXCEPTION WHEN OTHERS THEN
        GET STACKED DIAGNOSTICS v_state = RETURNED_SQLSTATE;
        INSERT INTO hasil_uji VALUES (4, 'item tanpa lokasi asal/tujuan', '23514', v_state,
            CASE WHEN v_state = '23514' THEN 'LULUS' ELSE 'GAGAL' END);
    END;

    -- Kasus 5: item mutasi merujuk barang yang tidak ada (FOREIGN KEY)
    BEGIN
        INSERT INTO mutasi_item (mutasi_id, barang_id, qty, lokasi_tujuan_id)
        VALUES ((SELECT id FROM mutasi WHERE kode = 'MUT-UJI-1'),
                999,
                10,
                (SELECT id FROM lokasi WHERE kode = 'G1-A-01'));
        INSERT INTO hasil_uji VALUES (5, 'item merujuk barang 999', '23503', NULL, 'GAGAL: diterima');
    EXCEPTION WHEN OTHERS THEN
        GET STACKED DIAGNOSTICS v_state = RETURNED_SQLSTATE;
        INSERT INTO hasil_uji VALUES (5, 'item merujuk barang 999', '23503', v_state,
            CASE WHEN v_state = '23503' THEN 'LULUS' ELSE 'GAGAL' END);
    END;

    -- Kasus 6: qty mutasi bernilai nol (CHECK qty > 0)
    BEGIN
        INSERT INTO mutasi_item (mutasi_id, barang_id, qty, lokasi_tujuan_id)
        VALUES ((SELECT id FROM mutasi WHERE kode = 'MUT-UJI-1'),
                (SELECT id FROM barang WHERE kode = 'BRG-001'),
                0,
                (SELECT id FROM lokasi WHERE kode = 'G1-A-01'));
        INSERT INTO hasil_uji VALUES (6, 'qty mutasi bernilai nol', '23514', NULL, 'GAGAL: diterima');
    EXCEPTION WHEN OTHERS THEN
        GET STACKED DIAGNOSTICS v_state = RETURNED_SQLSTATE;
        INSERT INTO hasil_uji VALUES (6, 'qty mutasi bernilai 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;

Output yang Dihasilkan:

text
CREATE TABLE
DO
 nomor |             kasus             | diharapkan | aktual | status 
-------+-------------------------------+------------+--------+--------
     1 | kode barang duplikat          | 23505      | 23505  | LULUS
     2 | stok_minimum negatif          | 23514      | 23514  | LULUS
     3 | tipe mutasi tidak dikenal     | 23514      | 23514  | LULUS
     4 | item tanpa lokasi asal/tujuan | 23514      | 23514  | LULUS
     5 | item merujuk barang 999       | 23503      | 23503  | LULUS
     6 | qty mutasi bernilai nol       | 23514      | 23514  | LULUS
(6 rows)

Enam kasus, enam vonis LULUS: kode duplikat ditolak UNIQUE (SQLSTATE 23505), stok minimum negatif dan tipe mutasi asing ditolak CHECK (23514), item tanpa lokasi ditolak num_nonnulls, barang yang tidak ada ditolak foreign key (23503), dan qty nol ditolak sebelum sempat tercatat. Bila kelak ada aturan bisnis baru — misalnya batas maksimum per satu mutasi — tempat menambahkannya sudah jelas: constraint di tabel, bukan if-else yang tersebar di banyak aplikasi.

Struktur penyimpanan kini berdiri dan teruji. Namun tabel stok_lokasi masih kosong dan mutasi belum berisi apa-apa — gudang ini belum pernah menerima barang. Bagian Pencatatan Stok menghidupkannya: barang mulai datang dari supplier, keluar menuju toko, dan setiap pergerakan berlaku sebagai satu transaksi utuh.

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 inventaris gudang.
-- Lima tabel: barang, lokasi, stok_lokasi, mutasi, dan mutasi_item.

CREATE TABLE barang (
    id           integer          GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    kode         text             NOT NULL UNIQUE,
    nama         text             NOT NULL,
    kategori     text             NOT NULL,
    satuan       text             NOT NULL,
    stok_minimum integer          NOT NULL CHECK (stok_minimum >= 0),
    harga_beli   numeric(12, 2)   NOT NULL CHECK (harga_beli > 0)
);

CREATE TABLE lokasi (
    id     integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    kode   text    NOT NULL UNIQUE,
    gudang text    NOT NULL,
    rak    text    NOT NULL,
    UNIQUE (gudang, rak)
);

CREATE TABLE stok_lokasi (
    barang_id integer NOT NULL REFERENCES barang (id) ON DELETE CASCADE,
    lokasi_id integer NOT NULL REFERENCES lokasi (id),
    qty       integer NOT NULL CONSTRAINT stok_qty_nonneg CHECK (qty >= 0),
    PRIMARY KEY (barang_id, lokasi_id)
);

CREATE TABLE mutasi (
    id         integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    kode       text    NOT NULL UNIQUE,
    tipe       text    NOT NULL CHECK (tipe IN ('masuk', 'keluar', 'pindah')),
    tanggal    date    NOT NULL,
    petugas    text    NOT NULL,
    keterangan text
);

CREATE TABLE mutasi_item (
    id               integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    mutasi_id        integer NOT NULL REFERENCES mutasi (id) ON DELETE CASCADE,
    barang_id        integer NOT NULL REFERENCES barang (id),
    qty              integer NOT NULL CHECK (qty > 0),
    lokasi_asal_id   integer REFERENCES lokasi (id),
    lokasi_tujuan_id integer REFERENCES lokasi (id),
    CONSTRAINT arah_lokasi_terisi
        CHECK (num_nonnulls(lokasi_asal_id, lokasi_tujuan_id) >= 1)
);

CREATE INDEX idx_mutasi_tanggal       ON mutasi (tanggal);
CREATE INDEX idx_mutasi_item_barang   ON mutasi_item (barang_id);
CREATE INDEX idx_mutasi_item_mutasi   ON mutasi_item (mutasi_id);

data-awal.sql

Unduh berkas
sql
-- data-awal.sql — data master: daftar barang dan lokasi penyimpanan.

INSERT INTO barang (kode, nama, kategori, satuan, stok_minimum, harga_beli) VALUES
    ('BRG-001', 'Kertas HVS A4 80gsm',   'Alat Tulis',  'box',    10, 185000.00),
    ('BRG-002', 'Pulpen Gel Hitam',      'Alat Tulis',  'box',    20,  42000.00),
    ('BRG-003', 'Buku Tulis 58 halaman', 'Alat Tulis',  'lusin',  30,  38500.00),
    ('BRG-004', 'Tinta Printer Hitam',   'Alat Tulis',  'pcs',     8,  95000.00),
    ('BRG-005', 'Sabun Cuci Cair 1L',    'Rumah Tangga','dus',    12,  87000.00),
    ('BRG-006', 'Deterjen Bubuk 800g',   'Rumah Tangga','dus',    15,  72000.00),
    ('BRG-007', 'Minyak Goreng 2L',      'Pangan',      'dus',    25, 115000.00),
    ('BRG-008', 'Beras Premium 5kg',     'Pangan',      'karung', 10, 320000.00),
    ('BRG-009', 'Kopi Sachet Instan',    'Pangan',      'dus',    40,  96000.00);

INSERT INTO lokasi (kode, gudang, rak) VALUES
    ('G1-A-01', 'Gudang Utama',     'Rak A-01'),
    ('G1-B-02', 'Gudang Utama',     'Rak B-02'),
    ('G1-C-03', 'Gudang Utama',     'Rak C-03'),
    ('G2-A-01', 'Gudang Cadangan',  'Rak A-01'),
    ('G2-B-02', 'Gudang Cadangan',  'Rak B-02');

SELECT id, kode, nama, satuan, stok_minimum, harga_beli
FROM barang
ORDER BY id;

SELECT id, kode, gudang, rak
FROM lokasi
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
    -- satu mutasi sah sebagai induk untuk kasus yang mengujinya
    INSERT INTO mutasi (kode, tipe, tanggal, petugas, keterangan)
    VALUES ('MUT-UJI-1', 'masuk', DATE '2026-02-01', 'Budi', 'mutasi untuk pengujian');

    -- Kasus 1: kode barang duplikat (UNIQUE)
    BEGIN
        INSERT INTO barang (kode, nama, kategori, satuan, stok_minimum, harga_beli)
        VALUES ('BRG-001', 'Kertas duplikat', 'Alat Tulis', 'box', 5, 1000.00);
        INSERT INTO hasil_uji VALUES (1, 'kode barang duplikat', '23505', NULL, 'GAGAL: diterima');
    EXCEPTION WHEN OTHERS THEN
        GET STACKED DIAGNOSTICS v_state = RETURNED_SQLSTATE;
        INSERT INTO hasil_uji VALUES (1, 'kode barang duplikat', '23505', v_state,
            CASE WHEN v_state = '23505' THEN 'LULUS' ELSE 'GAGAL' END);
    END;

    -- Kasus 2: stok_minimum negatif (CHECK)
    BEGIN
        INSERT INTO barang (kode, nama, kategori, satuan, stok_minimum, harga_beli)
        VALUES ('BRG-100', 'Barang rusak', 'Pangan', 'pcs', -5, 1000.00);
        INSERT INTO hasil_uji VALUES (2, 'stok_minimum negatif', '23514', NULL, 'GAGAL: diterima');
    EXCEPTION WHEN OTHERS THEN
        GET STACKED DIAGNOSTICS v_state = RETURNED_SQLSTATE;
        INSERT INTO hasil_uji VALUES (2, 'stok_minimum negatif', '23514', v_state,
            CASE WHEN v_state = '23514' THEN 'LULUS' ELSE 'GAGAL' END);
    END;

    -- Kasus 3: tipe mutasi tidak dikenal (CHECK)
    BEGIN
        INSERT INTO mutasi (kode, tipe, tanggal, petugas)
        VALUES ('MUT-UJI-2', 'hilang', DATE '2026-02-01', 'Budi');
        INSERT INTO hasil_uji VALUES (3, 'tipe mutasi tidak dikenal', '23514', NULL, 'GAGAL: diterima');
    EXCEPTION WHEN OTHERS THEN
        GET STACKED DIAGNOSTICS v_state = RETURNED_SQLSTATE;
        INSERT INTO hasil_uji VALUES (3, 'tipe mutasi tidak dikenal', '23514', v_state,
            CASE WHEN v_state = '23514' THEN 'LULUS' ELSE 'GAGAL' END);
    END;

    -- Kasus 4: item mutasi tanpa lokasi asal maupun tujuan (CHECK arah_lokasi_terisi)
    BEGIN
        INSERT INTO mutasi_item (mutasi_id, barang_id, qty)
        VALUES ((SELECT id FROM mutasi WHERE kode = 'MUT-UJI-1'),
                (SELECT id FROM barang WHERE kode = 'BRG-001'),
                10);
        INSERT INTO hasil_uji VALUES (4, 'item tanpa lokasi asal/tujuan', '23514', NULL, 'GAGAL: diterima');
    EXCEPTION WHEN OTHERS THEN
        GET STACKED DIAGNOSTICS v_state = RETURNED_SQLSTATE;
        INSERT INTO hasil_uji VALUES (4, 'item tanpa lokasi asal/tujuan', '23514', v_state,
            CASE WHEN v_state = '23514' THEN 'LULUS' ELSE 'GAGAL' END);
    END;

    -- Kasus 5: item mutasi merujuk barang yang tidak ada (FOREIGN KEY)
    BEGIN
        INSERT INTO mutasi_item (mutasi_id, barang_id, qty, lokasi_tujuan_id)
        VALUES ((SELECT id FROM mutasi WHERE kode = 'MUT-UJI-1'),
                999,
                10,
                (SELECT id FROM lokasi WHERE kode = 'G1-A-01'));
        INSERT INTO hasil_uji VALUES (5, 'item merujuk barang 999', '23503', NULL, 'GAGAL: diterima');
    EXCEPTION WHEN OTHERS THEN
        GET STACKED DIAGNOSTICS v_state = RETURNED_SQLSTATE;
        INSERT INTO hasil_uji VALUES (5, 'item merujuk barang 999', '23503', v_state,
            CASE WHEN v_state = '23503' THEN 'LULUS' ELSE 'GAGAL' END);
    END;

    -- Kasus 6: qty mutasi bernilai nol (CHECK qty > 0)
    BEGIN
        INSERT INTO mutasi_item (mutasi_id, barang_id, qty, lokasi_tujuan_id)
        VALUES ((SELECT id FROM mutasi WHERE kode = 'MUT-UJI-1'),
                (SELECT id FROM barang WHERE kode = 'BRG-001'),
                0,
                (SELECT id FROM lokasi WHERE kode = 'G1-A-01'));
        INSERT INTO hasil_uji VALUES (6, 'qty mutasi bernilai nol', '23514', NULL, 'GAGAL: diterima');
    EXCEPTION WHEN OTHERS THEN
        GET STACKED DIAGNOSTICS v_state = RETURNED_SQLSTATE;
        INSERT INTO hasil_uji VALUES (6, 'qty mutasi bernilai 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;