Gudang yang mengandalkan catatan manual hidup dengan dua ketakutan: barang yang “hilang” entah ke mana, dan stok yang menipis tanpa ada yang tahu lebih dulu. Perputaran barangnya pun dua arah sekaligus — masuk dari supplier, keluar ke toko, dan pindah antarlokasi karena penataan ulang — sehingga satu angka “stok gudang” saja tidak pernah cukup menjelaskan keadaan. Pada proyek ini kita membangun database inventaris untuk CV Maju Jaya, distributor barang kebutuhan harian dengan dua gedung penyimpanan, dari skema kosong sampai laporan nilai persediaan dan audit rekonsiliasi. Seperti proyek lain di section ini, batas keberhasilannya bukan “query-nya jalan”, melainkan seluruh aturan bisnis yang ditegakkan oleh PostgreSQL sendiri dan terbukti lewat eksekusi nyata.

Yang Akan Kita Bangun #

Sistem yang dirakit dari lima tabel menghasilkan fitur-fitur berikut:

  • Stok per lokasi, bukan stok global: satu barang dapat tersebar di lima rak pada dua gudang, dan sistem selalu tahu jumlah di masing-masing rak.
  • Riwayat mutasi lengkap — setiap kenaikan dan penurunan stok lahir dari dokumen mutasi bertipe masuk, keluar, atau pindah, lengkap dengan tanggal dan petugas.
  • Pergerakan atomik: penerimaan, pengiriman, dan perpindahan antarlokasi dibungkus transaksi BEGIN … COMMIT — termasuk bukti nyata bahwa transaksi yang gagal tidak meninggalkan setengah jejak.
  • Jaring pengaman constraint: stok negatif ditolak CHECK, barang tanpa lokasi ditolak num_nonnulls(), kode duplikat ditolak UNIQUE.
  • Laporan manajerial: posisi stok dibanding ambang minimum, nilai persediaan dalam rupiah per kategori, perputaran lambat beserta modal yang terkunci, dan rekonsiliasi yang menghitung ulang stok dari riwayat mutasi.

Seluruh contoh proyek ini dijalankan terhadap PostgreSQL 17 dengan data yang tetap (tanggal dan angka dipatok) sehingga setiap output dapat Anda bandingkan langsung.

Rancangan Data #

Lima tabel proyek ini dan hubungannya dapat diringkas dalam diagram berikut:

Dua keputusan rancangan menentukan watak seluruh proyek. Pertama, relasi barang–lokasi dimodelkan sebagai junction table stok_lokasi dengan primary key gabungan — inilah pola baku relasi many-to-many yang dibahas chapter Junction Table . Kedua, pergerakan dicatat sebagai pasangan kepala–detail (mutasi dan mutasi_item) meniru bentuk dokumen fisik gudang: satu surat jalan memuat banyak barang. Stok saat ini diperlakukan sebagai hasil yang selalu bisa dihitung ulang dari riwayat — dan Bagian 4 memang menghitungnya ulang sebagai audit.

Prasyarat #

Proyek ini memadatkan materi dari beberapa section sebelumnya. Pastikan Anda sudah nyaman dengan topik-topik berikut:

Tidak ada extension atau alat tambahan yang dibutuhkan — cukup psql dan PostgreSQL 17.

Alur Kerja dan Peta Bagian #

Proyek dikerjakan dalam empat bagian berurutan. Setiap bagian berdiri sendiri: berkas siapkan.sql di awal bagian memasang ulang skema dan data yang dibutuhkan, sehingga Anda dapat melompat ke bagian mana pun dengan database kosong dan tetap memperoleh hasil yang sama dengan halaman ini.

BagianFokus
Bagian 1: Perancangan DatabaseLima tabel, constraint bisnis, index, data master, dan uji pelanggaran
Bagian 2: Pencatatan StokMutasi masuk dan keluar dalam transaksi; bukti rollback saat stok kurang
Bagian 3: Transaksi BarangPerpindahan antarlokasi dua-sisi yang atomik, termasuk skenario gagalnya
Bagian 4: Laporan PersediaanStok kini, nilai persediaan, perputaran lambat, dan rekonsiliasi audit

Berkas Sumber #

Seluruh SQL proyek ini juga tersedia sebagai berkas siap pakai pada bagian Berkas Sumber di bawah halaman ini: folder database/ berisi skema, data master, dan riwayat aktivitas satu kuartal; folder queries/ berisi keempat laporan final; folder tests/ berisi uji constraint dan uji atomisitas yang dapat dijalankan ulang kapan saja. Jalankan berkasnya berurutan dari database/ lalu queries/ atau tests/ untuk memeriksa kondisi database Anda sendiri.

Setelah fondasi dan kebiasaan kerjanya jelas, tidak ada alasan menunda: mulailah dari Bagian 1: Perancangan Database — tempat lima tabel ini pertama kali ditulis dan diuji.

Berkas Sumber

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

database/aktivitas.sql

Unduh berkas
sql
-- aktivitas.sql — riwayat mutasi satu kuartal (Jan-Mar 2026):
-- 3 masuk, 3 keluar, 1 pindah, diterapkan set-based dalam satu transaksi.
-- Prasyarat: skema.sql dan data-awal.sql.

-- Riwayat aktivitas gudang Januari-Maret 2026: seluruh stok berasal
-- dari mutasi, diterapkan secara set-based dalam SATU transaksi.
BEGIN;

INSERT INTO mutasi (kode, tipe, tanggal, petugas, keterangan) VALUES
    ('MUT-0001', 'masuk',  DATE '2026-01-05', 'Budi', 'Penerimaan PO-1001 dari supplier'),
    ('MUT-0002', 'masuk',  DATE '2026-01-12', 'Budi', 'Penerimaan PO-1007 dari supplier'),
    ('MUT-0003', 'keluar', DATE '2026-01-20', 'Siti', 'Pengiriman ke Toko Pusat'),
    ('MUT-0004', 'pindah', DATE '2026-02-02', 'Budi', 'Penataan ulang: menyiapkan gudang cadangan'),
    ('MUT-0005', 'masuk',  DATE '2026-02-14', 'Budi', 'Penerimaan PO-1012 dari supplier'),
    ('MUT-0006', 'keluar', DATE '2026-03-05', 'Siti', 'Pengiriman ke Toko Pusat'),
    ('MUT-0007', 'keluar', DATE '2026-03-18', 'Siti', 'Pengiriman ke Toko Cabang');

INSERT INTO mutasi_item (mutasi_id, barang_id, qty, lokasi_asal_id, lokasi_tujuan_id)
SELECT m.id, b.id, v.qty, la.id, lt.id
FROM (VALUES
    ('MUT-0001', 'BRG-001', 20, NULL,      'G1-A-01'),
    ('MUT-0001', 'BRG-005', 15, NULL,      'G1-B-02'),
    ('MUT-0001', 'BRG-007', 40, NULL,      'G1-A-01'),
    ('MUT-0001', 'BRG-008', 12, NULL,      'G1-C-03'),
    ('MUT-0002', 'BRG-001', 10, NULL,      'G1-A-01'),
    ('MUT-0002', 'BRG-002', 30, NULL,      'G1-A-01'),
    ('MUT-0002', 'BRG-003', 40, NULL,      'G1-B-02'),
    ('MUT-0002', 'BRG-006', 18, NULL,      'G1-B-02'),
    ('MUT-0002', 'BRG-009', 45, NULL,      'G2-A-01'),
    ('MUT-0003', 'BRG-007', 12, 'G1-A-01', NULL),
    ('MUT-0003', 'BRG-008',  5, 'G1-C-03', NULL),
    ('MUT-0004', 'BRG-007', 10, 'G1-A-01', 'G2-A-01'),
    ('MUT-0004', 'BRG-009', 15, 'G2-A-01', 'G2-B-02'),
    ('MUT-0005', 'BRG-004', 10, NULL,      'G1-A-01'),
    ('MUT-0005', 'BRG-005', 10, NULL,      'G1-B-02'),
    ('MUT-0006', 'BRG-001', 15, 'G1-A-01', NULL),
    ('MUT-0006', 'BRG-003', 20, 'G1-B-02', NULL),
    ('MUT-0006', 'BRG-005', 18, 'G1-B-02', NULL),
    ('MUT-0007', 'BRG-002', 22, 'G1-A-01', NULL),
    ('MUT-0007', 'BRG-009', 20, 'G2-A-01', NULL)
) AS v(kode_mutasi, kode_barang, qty, asal, tujuan)
JOIN mutasi m       ON m.kode = v.kode_mutasi
JOIN barang b       ON b.kode = v.kode_barang
LEFT JOIN lokasi la ON la.kode = v.asal
LEFT JOIN lokasi lt ON lt.kode = v.tujuan;

-- efek 1: seluruh mutasi masuk menambah stok lokasi tujuan
INSERT INTO stok_lokasi (barang_id, lokasi_id, qty)
SELECT mi.barang_id, mi.lokasi_tujuan_id, SUM(mi.qty)
FROM mutasi_item mi
JOIN mutasi m ON m.id = mi.mutasi_id
WHERE m.tipe = 'masuk'
GROUP BY mi.barang_id, mi.lokasi_tujuan_id
ON CONFLICT (barang_id, lokasi_id) DO UPDATE
SET qty = stok_lokasi.qty + EXCLUDED.qty;

-- efek 2: seluruh pindahan mengurangi stok lokasi asal
UPDATE stok_lokasi sl
SET qty = sl.qty - d.qty_pindah
FROM (
    SELECT mi.barang_id, mi.lokasi_asal_id, SUM(mi.qty) AS qty_pindah
    FROM mutasi_item mi
    JOIN mutasi m ON m.id = mi.mutasi_id
    WHERE m.tipe = 'pindah'
    GROUP BY mi.barang_id, mi.lokasi_asal_id
) d
WHERE d.barang_id = sl.barang_id
  AND d.lokasi_asal_id = sl.lokasi_id;

-- efek 3: seluruh pindahan menambah stok lokasi tujuan
INSERT INTO stok_lokasi (barang_id, lokasi_id, qty)
SELECT mi.barang_id, mi.lokasi_tujuan_id, SUM(mi.qty)
FROM mutasi_item mi
JOIN mutasi m ON m.id = mi.mutasi_id
WHERE m.tipe = 'pindah'
GROUP BY mi.barang_id, mi.lokasi_tujuan_id
ON CONFLICT (barang_id, lokasi_id) DO UPDATE
SET qty = stok_lokasi.qty + EXCLUDED.qty;

-- efek 4: seluruh mutasi keluar mengurangi stok lokasi asal
UPDATE stok_lokasi sl
SET qty = sl.qty - d.qty_keluar
FROM (
    SELECT mi.barang_id, mi.lokasi_asal_id, SUM(mi.qty) AS qty_keluar
    FROM mutasi_item mi
    JOIN mutasi m ON m.id = mi.mutasi_id
    WHERE m.tipe = 'keluar'
    GROUP BY mi.barang_id, mi.lokasi_asal_id
) d
WHERE d.barang_id = sl.barang_id
  AND d.lokasi_asal_id = sl.lokasi_id;

COMMIT;

database/data-awal.sql

Unduh berkas
sql
-- data-awal.sql — data master: 9 barang dan 5 lokasi (2 gudang).
-- Prasyarat: skema.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');

database/skema.sql

Unduh berkas
sql
-- skema.sql — struktur lengkap database inventaris gudang.
-- Jalankan lebih dulu sebelum data-awal.sql, aktivitas.sql, queries/, atau tests/.

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

queries/laporan-stok.sql

Unduh berkas
sql
-- Prasyarat: jalankan database/skema.sql, database/data-awal.sql,
-- dan database/aktivitas.sql lebih dulu.

-- laporan-stok.sql — dua laporan pokok persediaan:
-- 1) stok total per barang dibandingkan dengan stok_minimum,
-- 2) sebaran stok per barang per lokasi.

-- Laporan 1: posisi stok dan status peringatan
SELECT b.kode,
       b.nama,
       b.kategori,
       b.satuan,
       b.stok_minimum,
       COALESCE(SUM(sl.qty), 0)                      AS stok_total,
       CASE WHEN COALESCE(SUM(sl.qty), 0) < b.stok_minimum
            THEN 'DI BAWAH MINIMUM' ELSE 'aman' END  AS status
FROM barang b
LEFT JOIN stok_lokasi sl ON sl.barang_id = b.id
GROUP BY b.id, b.kode, b.nama, b.kategori, b.satuan, b.stok_minimum
ORDER BY b.kode;

-- Laporan 2: barang tersebar di lokasi mana saja
SELECT b.kode,
       b.nama,
       l.gudang,
       l.rak,
       sl.qty
FROM stok_lokasi sl
JOIN barang b ON b.id = sl.barang_id
JOIN lokasi l ON l.id = sl.lokasi_id
ORDER BY b.kode, l.kode;

queries/nilai-persediaan.sql

Unduh berkas
sql
-- Prasyarat: jalankan database/skema.sql, database/data-awal.sql,
-- dan database/aktivitas.sql lebih dulu.

-- nilai-persediaan.sql — berapa rupiah modal yang sedang tertanam
-- di gudang: harga_beli dikali stok, per barang lalu per kategori.

-- Nilai persediaan per barang
SELECT b.kode,
       b.nama,
       b.kategori,
       COALESCE(SUM(sl.qty), 0)              AS stok,
       b.harga_beli,
       COALESCE(SUM(sl.qty), 0) * b.harga_beli AS nilai_persediaan
FROM barang b
LEFT JOIN stok_lokasi sl ON sl.barang_id = b.id
GROUP BY b.id, b.kode, b.nama, b.kategori, b.harga_beli
ORDER BY nilai_persediaan DESC, b.kode;

-- Ringkasan per kategori dengan total keseluruhan (ROLLUP)
WITH nilai AS (
    SELECT b.kategori,
           COALESCE(SUM(sl.qty), 0) * b.harga_beli AS nilai
    FROM barang b
    LEFT JOIN stok_lokasi sl ON sl.barang_id = b.id
    GROUP BY b.id, b.kategori, b.harga_beli
)
SELECT COALESCE(kategori, 'TOTAL SEMUA') AS kategori,
       SUM(nilai)                        AS nilai_persediaan
FROM nilai
GROUP BY ROLLUP (kategori)
ORDER BY kategori NULLS LAST;

queries/perputaran-lambat.sql

Unduh berkas
sql
-- Prasyarat: jalankan database/skema.sql, database/data-awal.sql,
-- dan database/aktivitas.sql lebih dulu.

-- perputaran-lambat.sql — barang yang tidak tersentuh mutasi dalam
-- waktu lama. Tanggal laporan dipatok ke 2026-04-01 agar hasil dapat
-- direproduksi; di produksi Anda menggantinya dengan CURRENT_DATE.

WITH aktivitas_terakhir AS (
    SELECT mi.barang_id, MAX(m.tanggal) AS tanggal_terakhir
    FROM mutasi_item mi
    JOIN mutasi m ON m.id = mi.mutasi_id
    GROUP BY mi.barang_id
),
posisi AS (
    SELECT b.id, b.kode, b.nama, b.kategori, b.harga_beli,
           COALESCE(SUM(sl.qty), 0) AS stok
    FROM barang b
    LEFT JOIN stok_lokasi sl ON sl.barang_id = b.id
    GROUP BY b.id, b.kode, b.nama, b.kategori, b.harga_beli
)
SELECT p.kode,
       p.nama,
       p.kategori,
       p.stok,
       at.tanggal_terakhir,
       (DATE '2026-04-01' - at.tanggal_terakhir) AS hari_sejak_mutasi,
       CASE
           WHEN (DATE '2026-04-01' - at.tanggal_terakhir) >= 60
               THEN 'perputaran lambat'
           WHEN (DATE '2026-04-01' - at.tanggal_terakhir) >= 30
               THEN 'waspada'
           ELSE 'aktif'
       END AS status_perputaran,
       p.stok * p.harga_beli AS modal_terkunci
FROM posisi p
LEFT JOIN aktivitas_terakhir at ON at.barang_id = p.id
ORDER BY hari_sejak_mutasi DESC, p.kode;

queries/rekonsiliasi.sql

Unduh berkas
sql
-- Prasyarat: jalankan database/skema.sql, database/data-awal.sql,
-- dan database/aktivitas.sql lebih dulu.

-- rekonsiliasi.sql — audit konsistensi: hitung ulang stok dari riwayat
-- mutasi, lalu bandingkan dengan isi tabel stok_lokasi.
-- Setiap mutasi_item berkontribusi +qty di lokasi tujuan dan -qty di
-- lokasi asal; pindah menyentuh dua lokasi sekaligus.

WITH arus AS (
    SELECT barang_id, lokasi_tujuan_id AS lokasi_id, qty
    FROM mutasi_item
    WHERE lokasi_tujuan_id IS NOT NULL
    UNION ALL
    SELECT barang_id, lokasi_asal_id, -qty
    FROM mutasi_item
    WHERE lokasi_asal_id IS NOT NULL
),
arus_per_lokasi AS (
    SELECT barang_id, lokasi_id, SUM(qty) AS perubahan_bersih
    FROM arus
    GROUP BY barang_id, lokasi_id
),
detail AS (
    SELECT b.kode,
           l.kode AS lokasi,
           COALESCE(a.perubahan_bersih, 0) AS dari_mutasi,
           COALESCE(sl.qty, 0)             AS di_stok_lokasi,
           COALESCE(sl.qty, 0) - COALESCE(a.perubahan_bersih, 0) AS selisih
    FROM arus_per_lokasi a
    FULL JOIN stok_lokasi sl
           ON sl.barang_id = a.barang_id AND sl.lokasi_id = a.lokasi_id
    JOIN barang b ON b.id = COALESCE(a.barang_id, sl.barang_id)
    JOIN lokasi l ON l.id = COALESCE(a.lokasi_id, sl.lokasi_id)
)
SELECT kode, lokasi, dari_mutasi, di_stok_lokasi, selisih
FROM detail
ORDER BY kode, lokasi;

-- Vonis rekonsiliasi: seluruh selisih harus nol
WITH arus AS (
    SELECT barang_id, lokasi_tujuan_id AS lokasi_id, qty
    FROM mutasi_item
    WHERE lokasi_tujuan_id IS NOT NULL
    UNION ALL
    SELECT barang_id, lokasi_asal_id, -qty
    FROM mutasi_item
    WHERE lokasi_asal_id IS NOT NULL
),
arus_per_lokasi AS (
    SELECT barang_id, lokasi_id, SUM(qty) AS perubahan_bersih
    FROM arus
    GROUP BY barang_id, lokasi_id
),
detail AS (
    SELECT COALESCE(sl.qty, 0) - COALESCE(a.perubahan_bersih, 0) AS selisih
    FROM arus_per_lokasi a
    FULL JOIN stok_lokasi sl
           ON sl.barang_id = a.barang_id AND sl.lokasi_id = a.lokasi_id
)
SELECT count(*)                                     AS baris_diperiksa,
       count(*) FILTER (WHERE selisih <> 0)         AS baris_selisih,
       CASE WHEN count(*) FILTER (WHERE selisih <> 0) = 0
            THEN 'REKONSILIASI LULUS'
            ELSE 'ADA SELISIH: PERIKSA MUTASI' END  AS vonis
FROM detail;

tests/uji-atomik.sql

Unduh berkas
sql
-- uji-atomik.sql — uji perilaku transaksional stok: pengeluaran yang
-- melebihi stok harus ditolak CHECK dan tidak boleh mengubah apa pun.
-- Prasyarat: jalankan database/skema.sql, database/data-awal.sql,
-- dan database/aktivitas.sql lebih dulu.

CREATE TEMP TABLE hasil_uji (
    nomor  integer,
    kasus  text,
    status text
);

DO $$
DECLARE
    v_sebelum integer;
    v_sesudah integer;
    v_ditolak boolean := false;
BEGIN
    SELECT sl.qty INTO v_sebelum
    FROM stok_lokasi sl
    JOIN barang b ON b.id = sl.barang_id
    JOIN lokasi l ON l.id = sl.lokasi_id
    WHERE b.kode = 'BRG-008' AND l.kode = 'G1-C-03';

    -- percobaan pengeluaran 30 karung dari stok 7: pasti melanggar CHECK.
    -- Blok EXCEPTION membuat subtransaksi: efek UPDATE yang gagal
    -- dibatalkan otomatis tanpa membatalkan uji ini.
    BEGIN
        UPDATE stok_lokasi sl
        SET qty = sl.qty - 30
        FROM barang b, lokasi l
        WHERE b.id = sl.barang_id
          AND l.id = sl.lokasi_id
          AND b.kode = 'BRG-008'
          AND l.kode = 'G1-C-03';
    EXCEPTION WHEN check_violation THEN
        v_ditolak := true;
    END;

    SELECT sl.qty INTO v_sesudah
    FROM stok_lokasi sl
    JOIN barang b ON b.id = sl.barang_id
    JOIN lokasi l ON l.id = sl.lokasi_id
    WHERE b.kode = 'BRG-008' AND l.kode = 'G1-C-03';

    INSERT INTO hasil_uji VALUES (1, 'stok awal skenario adalah 7',
        CASE WHEN v_sebelum = 7 THEN 'LULUS' ELSE 'GAGAL' END);
    INSERT INTO hasil_uji VALUES (2, 'pengeluaran 30 dari stok 7 ditolak CHECK',
        CASE WHEN v_ditolak THEN 'LULUS' ELSE 'GAGAL: lolos' END);
    INSERT INTO hasil_uji VALUES (3, 'stok tidak berubah setelah penolakan',
        CASE WHEN v_sebelum = v_sesudah THEN 'LULUS' ELSE 'GAGAL: berubah' END);
END $$;

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

tests/uji-constraint.sql

Unduh berkas
sql
-- Prasyarat: jalankan database/skema.sql, database/data-awal.sql,
-- dan database/aktivitas.sql lebih dulu.

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