Perpindahan antarlokasi adalah operasi paling rawan kesalahan dalam pengelolaan gudang: ia menyentuh dua baris stok sekaligus, satu dikurangi di rak asal dan satu ditambah di rak tujuan. Aplikasi yang menulis dua UPDATE terpisah tanpa transaksi membuka celah klasik — program berhenti di tengah jalan, barang sudah hilang dari rak asal tetapi belum muncul di rak tujuan, dan selisih itu sulit dicari karena tidak pernah tercatat di mana-mana. Pada bagian ini kita menutup celah tersebut: satu mutasi bertipe pindah menjadi dokumen tunggal yang menggerakkan stok dua lokasi secara atomik, dengan CHECK stok non-negatif sebagai batu pengawas.
Menyiapkan Fondasi Bagian #
Berkas siapkan.sql memasang ulang skema dan data master, lalu mengisi saldo awal gudang lewat dua mutasi masuk — persis pola Bagian 2. Dengan begitu seluruh stok tetap lahir dari mutasi, bukan dari angka yang diketik langsung, sehingga jejak auditnya utuh sejak baris pertama. Kondisi awal yang relevan untuk bagian ini: 40 dus Minyak Goreng di G1-A-01 dan 45 dus Kopi Sachet di G2-A-01.
-- siapkan.sql — fondasi Bagian 3: skema + data master + saldo awal.
-- Bagian ini dimaksudkan dapat dijalankan sendiri: struktur dipasang ulang,
-- lalu saldo awal diisi lewat dua mutasi masuk.
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');
-- Saldo awal gudang: seluruh stok berasal dari mutasi masuk,
-- sehingga jejak auditnya lengkap sejak hari pertama.
BEGIN;
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');
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;
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;
-- kondisi awal Bagian 3: dua aktor utama perpindahan adalah
-- BRG-007 (40 dus di G1-A-01) dan BRG-009 (45 dus di G2-A-01).
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:
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE INDEX
CREATE INDEX
CREATE INDEX
INSERT 0 9
INSERT 0 5
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)Memindahkan Barang Antarlokasi #
Dokumen MUT-0003 bertipe pindah memindahkan dua barang sekaligus: 10 dus Minyak Goreng dari Gudang Utama ke Gudang Cadangan, dan 15 dus Kopi dari Rak A-01 ke Rak B-02 di gudang cadangan yang sama. Setiap baris detail menulis lokasi_asal_id dan lokasi_tujuan_id — inilah satu-satunya tipe yang mengisi dua kolom lokasi sekaligus.
-- pindah-rak.sql — memindahkan barang antarlokasi dalam satu transaksi:
-- kurangi di lokasi asal, tambahkan di lokasi tujuan, keduanya atau tidak
-- sama sekali.
BEGIN;
-- 1) header mutasi pindah
INSERT INTO mutasi (kode, tipe, tanggal, petugas, keterangan) VALUES
('MUT-0003', 'pindah', DATE '2026-02-20', 'Budi',
'Penataan ulang: menyiapkan gudang cadangan');
-- 2) item pindah: lokasi asal DAN tujuan sama-sama wajib terisi
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-0003', 'BRG-007', 10, 'G1-A-01', 'G2-A-01'),
('MUT-0003', 'BRG-009', 15, 'G2-A-01', 'G2-B-02')
) 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
JOIN lokasi la ON la.kode = v.asal
JOIN lokasi lt ON lt.kode = v.tujuan;
-- 3) kurangi stok di lokasi asal lebih dulu;
-- bila stok tidak cukup, CHECK stok_qty_nonneg menggagalkan transaksi
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;
-- 4) tambahkan stok di lokasi tujuan (upsert: rak tujuan bisa masih kosong)
INSERT INTO stok_lokasi (barang_id, lokasi_id, qty)
SELECT mi.barang_id, mi.lokasi_tujuan_id, mi.qty
FROM mutasi_item mi
WHERE mi.mutasi_id = (SELECT id FROM mutasi WHERE kode = 'MUT-0003')
ON CONFLICT (barang_id, lokasi_id) DO UPDATE
SET qty = stok_lokasi.qty + EXCLUDED.qty;
COMMIT;
-- 5) verifikasi: barang pindah, jumlah totalnya tetap
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-009')
ORDER BY b.kode, l.kode;Output yang Dihasilkan:
BEGIN
INSERT 0 1
INSERT 0 2
UPDATE 2
INSERT 0 2
COMMIT
kode | nama | lokasi | qty
---------+--------------------+---------+-----
BRG-007 | Minyak Goreng 2L | G1-A-01 | 30
BRG-007 | Minyak Goreng 2L | G2-A-01 | 10
BRG-009 | Kopi Sachet Instan | G2-A-01 | 30
BRG-009 | Kopi Sachet Instan | G2-B-02 | 15
(4 rows)Urutan langkah di dalam transaksi bukan kebetulan:
- Kepala dan detail dulu:
INSERTkemutasidanmutasi_itemseperti biasa, supaya dokumen sumbernya lengkap sebelum stok disentuh. Bila langkah berikutnya gagal, seluruh transaksi — termasuk dokumen ini — ter-rollback bersama. - Asal dikurangi lebih dulu:
UPDATEpengurang dijalankan sebelum penambah tujuan. Bila stok asal ternyata tidak cukup,CHECK stok_qty_nonnegmeledak di sini — pada titik paling awal — dan penambahan di tujuan tidak pernah sempat dijalankan. Gagal secepat mungkin, kotoran sesedikit mungkin. - Tujuan ditambah lewat upsert: rak tujuan bisa saja belum pernah memuat barang itu (Kopi belum pernah ada di
G2-B-02), sehinggaON CONFLICT DO UPDATEmenangani kasus baris baru dan baris lama dengan satu perintah. - Verifikasi: empat baris hasil menunjukkan perpindahan yang seimbang — Minyak 30+10 dan Kopi 30+15, jumlah total masing-masing tetap 40 dan 45. Perpindahan tidak mengubah jumlah barang milik gudang, hanya alamatnya.
Perpindahan yang Gagal #
Sekarang giliran pembuktian atomisitasnya. MUT-0004 mencoba memindahkan 20 karung Beras dari G1-C-03 ke G2-B-02, padahal di lokasi asal hanya ada 12 karung:
-- pindah-gagal.sql — perpindahan yang melebihi stok asal.
-- Beras di G1-C-03 hanya 12 karung, MUT-0004 meminta pindah 20.
-- Pengurangan di lokasi asal gagal lebih dulu, sehingga penambahan
-- di lokasi tujuan tidak pernah sempat dijalankan.
BEGIN;
INSERT INTO mutasi (kode, tipe, tanggal, petugas, keterangan) VALUES
('MUT-0004', 'pindah', DATE '2026-02-22', 'Siti',
'Pemindahan beralasan gagal: stok asal kurang');
INSERT INTO mutasi_item (mutasi_id, barang_id, qty, lokasi_asal_id, lokasi_tujuan_id)
SELECT m.id, b.id, 20, la.id, lt.id
FROM mutasi m
JOIN barang b ON b.kode = 'BRG-008'
JOIN lokasi la ON la.kode = 'G1-C-03'
JOIN lokasi lt ON lt.kode = 'G2-B-02'
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;
INSERT INTO stok_lokasi (barang_id, lokasi_id, qty)
SELECT mi.barang_id, mi.lokasi_tujuan_id, mi.qty
FROM mutasi_item mi
WHERE mi.mutasi_id = (SELECT id FROM mutasi WHERE kode = 'MUT-0004')
ON CONFLICT (barang_id, lokasi_id) DO UPDATE
SET qty = stok_lokasi.qty + EXCLUDED.qty;
COMMIT;Output yang Dihasilkan:
psql:pindah-gagal.sql:25: ERROR: new row for relation "stok_lokasi" violates check constraint "stok_qty_nonneg"
DETAIL: Failing row contains (8, 3, -8).Kegagalan terjadi pada UPDATE baris 25 — pengurangan lokasi asal — dengan check_violation yang sama seperti Bagian 2: baris pengganti berisi (8, 3, -8), artinya qty akan menjadi -8. Psql berhenti di situ karena ON_ERROR_STOP, COMMIT tak tercapai, dan server membatalkan seluruh transaksi. Berkas verifikasi berikut membuktikan bahwa tidak ada setengah perpindahan yang tersisa:
-- pindah-pulih.sql — bukti atomisitas: perpindahan yang gagal tidak
-- meninggalkan setengah jejak. Beras tetap 12 di G1-C-03, tidak ada
-- baris Beras di gudang cadangan, dan 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 kode, tipe, petugas
FROM mutasi
ORDER BY id;Output yang Dihasilkan:
kode | nama | lokasi | qty
---------+-------------------+---------+-----
BRG-008 | Beras Premium 5kg | G1-C-03 | 12
(1 row)
kode | tipe | petugas
----------+--------+---------
MUT-0001 | masuk | Budi
MUT-0002 | masuk | Budi
MUT-0003 | pindah | Budi
(3 rows)Dua fakta pada output itu adalah bukti atomisitas: Beras hanya muncul di satu lokasi (G1-C-03, tetap 12 karung — tidak ada baris hantu di G2-B-02), dan tabel mutasi hanya berisi tiga dokumen: dua masuk dan satu pindah yang sukses. MUT-0004 lenyap tanpa jejak, meskipun INSERT-nya berada di baris awal transaksi dan kegagalannya terjadi belakangan.
Pola yang sama tersedia untuk kasus nyata lain: memindahkan barang yang Rusak ke lokasi karantina, menata ulang rak setelah stock opname, atau memecat kiriman besar ke beberapa rak. Selama dua sisi perpindahan hidup dalam satu transaksi dan stok asal divalidasi lebih dulu, gudang tidak akan pernah kehilangan barang di antara dua rak.
Tiga arus — masuk, keluar, pindah — kini berjalan dan teruji, dan tabel mutasi mulai berisi sejarah yang layak dipercaya. Sayangnya data yang benar belum berarti bisnis yang sehat: berapa rupiah modal yang tertanam? barang mana yang harus segera dipesan ulang? mana yang menumpak tak tersentuh? Bagian Laporan Persediaan
menjawabnya dengan query analisis di atas data yang baru kita bangun.
Referensi #
- BEGIN — membuka transaksi yang memuat pengurangan asal dan penambahan tujuan.
- COMMIT — mengikat seluruh perubahan transaksi secara permanen.
- Constraints pada Tabel
—
CHECKsebagai penjaga invariant qty >= 0. - Isolation Levels — perilaku konkuren ketika dua petugas memindahkan stok yang sama.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
pindah-gagal.sql
Unduh berkas-- pindah-gagal.sql — perpindahan yang melebihi stok asal.
-- Beras di G1-C-03 hanya 12 karung, MUT-0004 meminta pindah 20.
-- Pengurangan di lokasi asal gagal lebih dulu, sehingga penambahan
-- di lokasi tujuan tidak pernah sempat dijalankan.
BEGIN;
INSERT INTO mutasi (kode, tipe, tanggal, petugas, keterangan) VALUES
('MUT-0004', 'pindah', DATE '2026-02-22', 'Siti',
'Pemindahan beralasan gagal: stok asal kurang');
INSERT INTO mutasi_item (mutasi_id, barang_id, qty, lokasi_asal_id, lokasi_tujuan_id)
SELECT m.id, b.id, 20, la.id, lt.id
FROM mutasi m
JOIN barang b ON b.kode = 'BRG-008'
JOIN lokasi la ON la.kode = 'G1-C-03'
JOIN lokasi lt ON lt.kode = 'G2-B-02'
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;
INSERT INTO stok_lokasi (barang_id, lokasi_id, qty)
SELECT mi.barang_id, mi.lokasi_tujuan_id, mi.qty
FROM mutasi_item mi
WHERE mi.mutasi_id = (SELECT id FROM mutasi WHERE kode = 'MUT-0004')
ON CONFLICT (barang_id, lokasi_id) DO UPDATE
SET qty = stok_lokasi.qty + EXCLUDED.qty;
COMMIT;
pindah-pulih.sql
Unduh berkas-- pindah-pulih.sql — bukti atomisitas: perpindahan yang gagal tidak
-- meninggalkan setengah jejak. Beras tetap 12 di G1-C-03, tidak ada
-- baris Beras di gudang cadangan, dan 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 kode, tipe, petugas
FROM mutasi
ORDER BY id;
pindah-rak.sql
Unduh berkas-- pindah-rak.sql — memindahkan barang antarlokasi dalam satu transaksi:
-- kurangi di lokasi asal, tambahkan di lokasi tujuan, keduanya atau tidak
-- sama sekali.
BEGIN;
-- 1) header mutasi pindah
INSERT INTO mutasi (kode, tipe, tanggal, petugas, keterangan) VALUES
('MUT-0003', 'pindah', DATE '2026-02-20', 'Budi',
'Penataan ulang: menyiapkan gudang cadangan');
-- 2) item pindah: lokasi asal DAN tujuan sama-sama wajib terisi
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-0003', 'BRG-007', 10, 'G1-A-01', 'G2-A-01'),
('MUT-0003', 'BRG-009', 15, 'G2-A-01', 'G2-B-02')
) 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
JOIN lokasi la ON la.kode = v.asal
JOIN lokasi lt ON lt.kode = v.tujuan;
-- 3) kurangi stok di lokasi asal lebih dulu;
-- bila stok tidak cukup, CHECK stok_qty_nonneg menggagalkan transaksi
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;
-- 4) tambahkan stok di lokasi tujuan (upsert: rak tujuan bisa masih kosong)
INSERT INTO stok_lokasi (barang_id, lokasi_id, qty)
SELECT mi.barang_id, mi.lokasi_tujuan_id, mi.qty
FROM mutasi_item mi
WHERE mi.mutasi_id = (SELECT id FROM mutasi WHERE kode = 'MUT-0003')
ON CONFLICT (barang_id, lokasi_id) DO UPDATE
SET qty = stok_lokasi.qty + EXCLUDED.qty;
COMMIT;
-- 5) verifikasi: barang pindah, jumlah totalnya tetap
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-009')
ORDER BY b.kode, l.kode;
siapkan.sql
Unduh berkas-- siapkan.sql — fondasi Bagian 3: skema + data master + saldo awal.
-- Bagian ini dimaksudkan dapat dijalankan sendiri: struktur dipasang ulang,
-- lalu saldo awal diisi lewat dua mutasi masuk.
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');
-- Saldo awal gudang: seluruh stok berasal dari mutasi masuk,
-- sehingga jejak auditnya lengkap sejak hari pertama.
BEGIN;
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');
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;
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;
-- kondisi awal Bagian 3: dua aktor utama perpindahan adalah
-- BRG-007 (40 dus di G1-A-01) dan BRG-009 (45 dus di G2-A-01).
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;