Prinsip kerja gudang ini sederhana namun ketat: tabel stok_lokasi tidak pernah diubah secara langsung tanpa alasan. Setiap kenaikan dan penurunan stok harus lahir dari sebuah mutasi — dokumen sumber yang mencatat siapa, kapan, barang apa, jumlah berapa, dan di lokasi mana. Dengan disiplin itu, pertanyaan “kok stok Kertas tinggal 30?” selalu punya jawaban yang bisa ditelusuri sampai ke surat jalan. Pada bagian ini kita menghidupkan mekanisme tersebut untuk dua arus pertama: barang masuk dari supplier dan barang keluar menuju toko.

Menyiapkan Fondasi Bagian #

Setiap bagian proyek ini dirancang dapat dijalankan sendiri, jadi berkas siapkan.sql memasang ulang skema dan data master dari Bagian 1 ke database yang masih kosong. Pemeriksaan singkat di akhir berkas memastikan sembilan barang dan lima lokasi sudah siap, sementara stok_lokasi memang masih kosong: gudang belum menerima kiriman apa pun.

sql siapkan.sql
-- siapkan.sql — fondasi Bagian 2: skema dari Bagian 1 + data master.
-- Bagian ini dimaksudkan dapat dijalankan sendiri, sehingga seluruh
-- struktur dan data master dipasang ulang dari nol.

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

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 (SELECT count(*) FROM barang) AS jumlah_barang,
       (SELECT count(*) FROM lokasi)  AS jumlah_lokasi,
       (SELECT count(*) FROM stok_lokasi) AS baris_stok;

Output yang Dihasilkan:

text
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE INDEX
CREATE INDEX
CREATE INDEX
INSERT 0 9
INSERT 0 5
 jumlah_barang | jumlah_lokasi | baris_stok 
---------------+---------------+------------
             9 |             5 |          0
(1 row)

Mutasi Masuk: Barang Datang dari Supplier #

Dokumen MUT-0001 dan MUT-0002 adalah dua penerimaan barang pada awal Februari 2026. Ketiganya — kepala mutasi, baris detail, dan kenaikan stok — dibungkus satu BEGIN … COMMIT sehingga tidak ada kemungkinan mutasi tercatat tetapi stok tidak naik.

-- mutasi-masuk.sql — barang datang dari supplier: satu transaksi utuh.
-- Header mutasi, item mutasi, dan kenaikan stok_lokasi terjadi semua
-- atau tidak sama sekali.

BEGIN;

-- 1) catat header mutasi masuk
INSERT INTO mutasi (kode, tipe, tanggal, petugas, keterangan) VALUES
    ('MUT-0001', 'masuk', DATE '2026-02-02', 'Budi',
     'Penerimaan PO-1001 dari supplier'),
    ('MUT-0002', 'masuk', DATE '2026-02-10', 'Budi',
     'Penerimaan PO-1007 dari supplier');

-- 2) catat item per barang, lengkap dengan lokasi tujuan
INSERT INTO mutasi_item (mutasi_id, barang_id, qty, lokasi_tujuan_id)
SELECT m.id, b.id, v.qty, l.id
FROM (VALUES
    ('MUT-0001', 'BRG-001', 20, 'G1-A-01'),
    ('MUT-0001', 'BRG-005', 15, 'G1-B-02'),
    ('MUT-0001', 'BRG-007', 40, 'G1-A-01'),
    ('MUT-0001', 'BRG-008', 12, 'G1-C-03'),
    ('MUT-0002', 'BRG-001', 10, 'G1-A-01'),
    ('MUT-0002', 'BRG-002', 30, 'G1-A-01'),
    ('MUT-0002', 'BRG-003', 40, 'G1-B-02'),
    ('MUT-0002', 'BRG-006', 18, 'G1-B-02'),
    ('MUT-0002', 'BRG-009', 45, 'G2-A-01')
) AS v(kode_mutasi, kode_barang, qty, kode_lokasi)
JOIN mutasi m  ON m.kode = v.kode_mutasi
JOIN barang b  ON b.kode = v.kode_barang
JOIN lokasi l  ON l.kode = v.kode_lokasi;

-- 3) naikkan stok di lokasi tujuan; baris baru dibuat bila belum ada.
--    SUM diperlukan karena dua mutasi dapat menambah barang yang sama
--    di lokasi yang sama (BRG-001 ke G1-A-01 di MUT-0001 dan MUT-0002),
--    dan ON CONFLICT DO UPDATE tidak boleh menyentuh satu baris dua
--    kali dalam perintah yang sama.
INSERT INTO stok_lokasi (barang_id, lokasi_id, qty)
SELECT mi.barang_id, mi.lokasi_tujuan_id, SUM(mi.qty)
FROM mutasi_item mi
WHERE mi.mutasi_id IN (SELECT id FROM mutasi
                       WHERE kode IN ('MUT-0001', 'MUT-0002'))
GROUP BY mi.barang_id, mi.lokasi_tujuan_id
ON CONFLICT (barang_id, lokasi_id) DO UPDATE
SET qty = stok_lokasi.qty + EXCLUDED.qty;

COMMIT;

-- 4) verifikasi hasil
SELECT b.kode, b.nama, l.kode AS lokasi, 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;

Output yang Dihasilkan:

text
BEGIN
INSERT 0 2
INSERT 0 9
INSERT 0 8
COMMIT
  kode   |         nama          | lokasi  | qty 
---------+-----------------------+---------+-----
 BRG-001 | Kertas HVS A4 80gsm   | G1-A-01 |  30
 BRG-002 | Pulpen Gel Hitam      | G1-A-01 |  30
 BRG-003 | Buku Tulis 58 halaman | G1-B-02 |  40
 BRG-005 | Sabun Cuci Cair 1L    | G1-B-02 |  15
 BRG-006 | Deterjen Bubuk 800g   | G1-B-02 |  18
 BRG-007 | Minyak Goreng 2L      | G1-A-01 |  40
 BRG-008 | Beras Premium 5kg     | G1-C-03 |  12
 BRG-009 | Kopi Sachet Instan    | G2-A-01 |  45
(8 rows)

Empat bagian perintah di atas bekerja sebagai satu kesatuan:

  1. Langkah 1 — kepala mutasi: dua baris untuk MUT-0001 dan MUT-0002 dengan tipe masuk, tanggal, petugas, dan keterangan nomor PO. Kolom tipe akan ditolak CHECK bila isinya bukan masuk, keluar, atau pindah.
  2. Langkah 2 — baris detail: daftar VALUES berisi sembilan pasangan barang–lokasi di-join ke tabel master untuk menerjemahkan kode teks menjadi id. Menyimpan id, bukan teks kode, membuat relasi tetap benar meski suatu saat nama lokasi dirapikan.
  3. Langkah 3 — kenaikan stok: INSERT ... ON CONFLICT DO UPDATE menangani dua keadaan sekaligus: pasangan barang–lokasi yang belum ada dibuat barisnya, yang sudah ada ditambah qty-nya. SUM(...) dengan GROUP BY bukan hiasan — tanpa agregasi ini, perintah akan mengenai baris yang sama dua kali dan PostgreSQL menolaknya dengan pesan ON CONFLICT DO UPDATE command cannot affect row a second time, karena BRG-001 memang dikirim ke G1-A-01 oleh dua mutasi yang berbeda.
  4. Langkah 4 — verifikasi: hasil akhir delapan baris stok, dengan BRG-001 di G1-A-01 berjumlah 30 — gabungan 20 dari MUT-0001 dan 10 dari MUT-0002.

Mutasi Keluar: Barang Dikirim ke Toko #

Seminggu kemudian gudang mengirim barang ke Toko Pusat lewat MUT-0003. Polanya mirip, tetapi arah stoknya terbalik: detail menulis lokasi_asal_id (tujuan dibiarkan NULL), dan UPDATE ... FROM menurunkan stok di lokasi asal.

-- mutasi-keluar.sql — barang keluar dari lokasi penyimpanan.
-- UPDATE pengurang dibiarkan "polos": CHECK qty >= 0 pada stok_lokasi
-- yang menjadi jaring pengaman bila stok tidak mencukupi.

BEGIN;

-- 1) header mutasi keluar
INSERT INTO mutasi (kode, tipe, tanggal, petugas, keterangan) VALUES
    ('MUT-0003', 'keluar', DATE '2026-02-15', 'Siti',
     'Pengiriman ke Toko Pusat');

-- 2) item keluar: lokasi asal wajib, tujuan kosong
INSERT INTO mutasi_item (mutasi_id, barang_id, qty, lokasi_asal_id)
SELECT m.id, b.id, v.qty, l.id
FROM (VALUES
    ('MUT-0003', 'BRG-007', 12, 'G1-A-01'),
    ('MUT-0003', 'BRG-008',  5, 'G1-C-03')
) AS v(kode_mutasi, kode_barang, qty, kode_lokasi)
JOIN mutasi m  ON m.kode = v.kode_mutasi
JOIN barang b  ON b.kode = v.kode_barang
JOIN lokasi l  ON l.kode = v.kode_lokasi;

-- 3) kurangi stok di lokasi asal
UPDATE stok_lokasi sl
SET qty = sl.qty - mi.qty
FROM mutasi_item mi
WHERE mi.mutasi_id = (SELECT id FROM mutasi WHERE kode = 'MUT-0003')
  AND mi.barang_id      = sl.barang_id
  AND mi.lokasi_asal_id = sl.lokasi_id;

COMMIT;

-- 4) verifikasi: stok BRG-007 dan BRG-008 kini berkurang
SELECT b.kode, b.nama, l.kode AS lokasi, sl.qty
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 IN ('BRG-007', 'BRG-008')
ORDER BY b.kode, l.kode;

Output yang Dihasilkan:

text
BEGIN
INSERT 0 1
INSERT 0 2
UPDATE 2
COMMIT
  kode   |       nama        | lokasi  | qty 
---------+-------------------+---------+-----
 BRG-007 | Minyak Goreng 2L  | G1-A-01 |  28
 BRG-008 | Beras Premium 5kg | G1-C-03 |   7
(2 rows)

Perhatikan bahwa UPDATE pengurangnya sama sekali tidak memeriksa apakah stok cukup — tidak ada WHERE qty >= permintaan. Pemeriksaan itu dipercayakan sepenuhnya pada CHECK (qty >= 0) di tabel stok_lokasi. Konsekuensinya besar: klaim pengurangan yang melebihi stok digagalkan oleh constraint, dan karena seluruh perintah berada dalam satu transaksi, seluruh mutasi itu ikut dibatalkan.

Ketika Stok Tidak Mencukupi #

Mari kita buktikan klaim itu dengan sengaja melakukan kesalahan. MUT-0004 ingin mengirim 30 karung Beras dari G1-C-03, padahal di sana hanya tersisa 7 karung:

-- keluar-gagal.sql — pengeluaran yang melebihi stok tersedia.
-- Beras di G1-C-03 tinggal 7 karung, tetapi MUT-0004 meminta 30.
-- CHECK stok_qty_nonneg menolak UPDATE, transaksi batal seluruhnya.

BEGIN;

INSERT INTO mutasi (kode, tipe, tanggal, petugas, keterangan) VALUES
    ('MUT-0004', 'keluar', DATE '2026-02-18', 'Siti',
     'Pengiriman ke Toko Cabang');

INSERT INTO mutasi_item (mutasi_id, barang_id, qty, lokasi_asal_id)
SELECT m.id, b.id, 30, l.id
FROM mutasi m
JOIN barang b ON b.kode = 'BRG-008'
JOIN lokasi l ON l.kode = 'G1-C-03'
WHERE m.kode = 'MUT-0004';

UPDATE stok_lokasi sl
SET qty = sl.qty - mi.qty
FROM mutasi_item mi
WHERE mi.mutasi_id = (SELECT id FROM mutasi WHERE kode = 'MUT-0004')
  AND mi.barang_id      = sl.barang_id
  AND mi.lokasi_asal_id = sl.lokasi_id;

COMMIT;

Output yang Dihasilkan:

text
psql:keluar-gagal.sql:23: ERROR:  new row for relation "stok_lokasi" violates check constraint "stok_qty_nonneg"
DETAIL:  Failing row contains (8, 3, -23).

PostgreSQL membatalkan UPDATE dengan check_violation pada constraint stok_qty_nonneg — baris pengganti berisi (8, 3, -23): barang 8, lokasi 3, qty -23. Karena ON_ERROR_STOP membuat psql berhenti di titik itu, COMMIT tidak pernah sampai dieksekusi dan server mengembalikan seluruh transaksi. Berkas berikut membuktikan bahwa tidak ada jejak yang tertinggal:

sql cek-pulih.sql
-- cek-pulih.sql — bukti bahwa transaksi yang gagal tidak meninggalkan jejak:
-- stok Beras tidak berubah dan header MUT-0004 tidak pernah tersimpan.

SELECT b.kode, b.nama, l.kode AS lokasi, sl.qty
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'
ORDER BY l.kode;

SELECT count(*) AS jumlah_mutasi_tersimpan
FROM mutasi;

Output yang Dihasilkan:

text
  kode   |       nama        | lokasi  | qty 
---------+-------------------+---------+-----
 BRG-008 | Beras Premium 5kg | G1-C-03 |   7
(1 row)

 jumlah_mutasi_tersimpan 
-------------------------
                       3
(1 row)

Beras masih 7 karung di G1-C-03 — angka yang sama seperti sebelum percobaan gagal — dan jumlah mutasi tersimpan tetap 3. Kepala MUT-0004 yang sempat di-INSERT di awal transaksi ikut lenyap; inilah atomisitas yang dijanjikan transaksi database: semua perubahan dari satu dokumen terjadi bersama-sama, atau tidak sama sekali.

Arus masuk dan keluar kini berjalan dengan disiplin yang teruji, termasuk kegagalannya. Satu jenis pergerakan masih tersisa dan justru yang paling berisiko membuat barang “hilang di antara dua rak”: perpindahan antarlokasi, yang menyentuh dua baris stok sekaligus. Bagian Transaksi Barang membuktikan bahwa perpindahan seperti itu tetap utuh atau batal seluruhnya.

Referensi #

  • BEGIN — membuka blok transaksi yang akan dikomit atau dibatalkan sebagai satu kesatuan.
  • INSERT — sintaks ON CONFLICT DO UPDATE dan aturan satu-baris-sekali-sentuh.
  • UPDATE — pola UPDATE ... FROM untuk pengurangan stok berbasis detail mutasi.
  • Tutorial: Transactions — pengantar konsep atomisitas transaksi di PostgreSQL.

Berkas Sumber

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

cek-pulih.sql

Unduh berkas
sql
-- cek-pulih.sql — bukti bahwa transaksi yang gagal tidak meninggalkan jejak:
-- stok Beras tidak berubah dan header MUT-0004 tidak pernah tersimpan.

SELECT b.kode, b.nama, l.kode AS lokasi, sl.qty
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'
ORDER BY l.kode;

SELECT count(*) AS jumlah_mutasi_tersimpan
FROM mutasi;

keluar-gagal.sql

Unduh berkas
sql
-- keluar-gagal.sql — pengeluaran yang melebihi stok tersedia.
-- Beras di G1-C-03 tinggal 7 karung, tetapi MUT-0004 meminta 30.
-- CHECK stok_qty_nonneg menolak UPDATE, transaksi batal seluruhnya.

BEGIN;

INSERT INTO mutasi (kode, tipe, tanggal, petugas, keterangan) VALUES
    ('MUT-0004', 'keluar', DATE '2026-02-18', 'Siti',
     'Pengiriman ke Toko Cabang');

INSERT INTO mutasi_item (mutasi_id, barang_id, qty, lokasi_asal_id)
SELECT m.id, b.id, 30, l.id
FROM mutasi m
JOIN barang b ON b.kode = 'BRG-008'
JOIN lokasi l ON l.kode = 'G1-C-03'
WHERE m.kode = 'MUT-0004';

UPDATE stok_lokasi sl
SET qty = sl.qty - mi.qty
FROM mutasi_item mi
WHERE mi.mutasi_id = (SELECT id FROM mutasi WHERE kode = 'MUT-0004')
  AND mi.barang_id      = sl.barang_id
  AND mi.lokasi_asal_id = sl.lokasi_id;

COMMIT;

mutasi-keluar.sql

Unduh berkas
sql
-- mutasi-keluar.sql — barang keluar dari lokasi penyimpanan.
-- UPDATE pengurang dibiarkan "polos": CHECK qty >= 0 pada stok_lokasi
-- yang menjadi jaring pengaman bila stok tidak mencukupi.

BEGIN;

-- 1) header mutasi keluar
INSERT INTO mutasi (kode, tipe, tanggal, petugas, keterangan) VALUES
    ('MUT-0003', 'keluar', DATE '2026-02-15', 'Siti',
     'Pengiriman ke Toko Pusat');

-- 2) item keluar: lokasi asal wajib, tujuan kosong
INSERT INTO mutasi_item (mutasi_id, barang_id, qty, lokasi_asal_id)
SELECT m.id, b.id, v.qty, l.id
FROM (VALUES
    ('MUT-0003', 'BRG-007', 12, 'G1-A-01'),
    ('MUT-0003', 'BRG-008',  5, 'G1-C-03')
) AS v(kode_mutasi, kode_barang, qty, kode_lokasi)
JOIN mutasi m  ON m.kode = v.kode_mutasi
JOIN barang b  ON b.kode = v.kode_barang
JOIN lokasi l  ON l.kode = v.kode_lokasi;

-- 3) kurangi stok di lokasi asal
UPDATE stok_lokasi sl
SET qty = sl.qty - mi.qty
FROM mutasi_item mi
WHERE mi.mutasi_id = (SELECT id FROM mutasi WHERE kode = 'MUT-0003')
  AND mi.barang_id      = sl.barang_id
  AND mi.lokasi_asal_id = sl.lokasi_id;

COMMIT;

-- 4) verifikasi: stok BRG-007 dan BRG-008 kini berkurang
SELECT b.kode, b.nama, l.kode AS lokasi, sl.qty
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 IN ('BRG-007', 'BRG-008')
ORDER BY b.kode, l.kode;

mutasi-masuk.sql

Unduh berkas
sql
-- mutasi-masuk.sql — barang datang dari supplier: satu transaksi utuh.
-- Header mutasi, item mutasi, dan kenaikan stok_lokasi terjadi semua
-- atau tidak sama sekali.

BEGIN;

-- 1) catat header mutasi masuk
INSERT INTO mutasi (kode, tipe, tanggal, petugas, keterangan) VALUES
    ('MUT-0001', 'masuk', DATE '2026-02-02', 'Budi',
     'Penerimaan PO-1001 dari supplier'),
    ('MUT-0002', 'masuk', DATE '2026-02-10', 'Budi',
     'Penerimaan PO-1007 dari supplier');

-- 2) catat item per barang, lengkap dengan lokasi tujuan
INSERT INTO mutasi_item (mutasi_id, barang_id, qty, lokasi_tujuan_id)
SELECT m.id, b.id, v.qty, l.id
FROM (VALUES
    ('MUT-0001', 'BRG-001', 20, 'G1-A-01'),
    ('MUT-0001', 'BRG-005', 15, 'G1-B-02'),
    ('MUT-0001', 'BRG-007', 40, 'G1-A-01'),
    ('MUT-0001', 'BRG-008', 12, 'G1-C-03'),
    ('MUT-0002', 'BRG-001', 10, 'G1-A-01'),
    ('MUT-0002', 'BRG-002', 30, 'G1-A-01'),
    ('MUT-0002', 'BRG-003', 40, 'G1-B-02'),
    ('MUT-0002', 'BRG-006', 18, 'G1-B-02'),
    ('MUT-0002', 'BRG-009', 45, 'G2-A-01')
) AS v(kode_mutasi, kode_barang, qty, kode_lokasi)
JOIN mutasi m  ON m.kode = v.kode_mutasi
JOIN barang b  ON b.kode = v.kode_barang
JOIN lokasi l  ON l.kode = v.kode_lokasi;

-- 3) naikkan stok di lokasi tujuan; baris baru dibuat bila belum ada.
--    SUM diperlukan karena dua mutasi dapat menambah barang yang sama
--    di lokasi yang sama (BRG-001 ke G1-A-01 di MUT-0001 dan MUT-0002),
--    dan ON CONFLICT DO UPDATE tidak boleh menyentuh satu baris dua
--    kali dalam perintah yang sama.
INSERT INTO stok_lokasi (barang_id, lokasi_id, qty)
SELECT mi.barang_id, mi.lokasi_tujuan_id, SUM(mi.qty)
FROM mutasi_item mi
WHERE mi.mutasi_id IN (SELECT id FROM mutasi
                       WHERE kode IN ('MUT-0001', 'MUT-0002'))
GROUP BY mi.barang_id, mi.lokasi_tujuan_id
ON CONFLICT (barang_id, lokasi_id) DO UPDATE
SET qty = stok_lokasi.qty + EXCLUDED.qty;

COMMIT;

-- 4) verifikasi hasil
SELECT b.kode, b.nama, l.kode AS lokasi, 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;

siapkan.sql

Unduh berkas
sql
-- siapkan.sql — fondasi Bagian 2: skema dari Bagian 1 + data master.
-- Bagian ini dimaksudkan dapat dijalankan sendiri, sehingga seluruh
-- struktur dan data master dipasang ulang dari nol.

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

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 (SELECT count(*) FROM barang) AS jumlah_barang,
       (SELECT count(*) FROM lokasi)  AS jumlah_lokasi,
       (SELECT count(*) FROM stok_lokasi) AS baris_stok;