Database inventaris membayar biaya penyimpanannya pada saat laporan pertama dicetak. Sampai titik ini kita disibukkan menjaga kebenaran data; sekarang datanya bekerja untuk kita: manajemen ingin tahu posisi stok dibanding ambang minimum, tim keuangan ingin tahu berapa rupiah modal yang tertanam di gudang, dan pembeli ingin tahu barang mana yang perlu dipesan ulang — atau justru jangan dipesan karena tidak laku-laku. Pada bagian penutup proyek ini kita menyusun empat laporan dari data yang dibangun sejak Bagian 1, ditutup rekonsiliasi yang menghitung ulang seluruh stok dari riwayat mutasi sebagai bukti akhir bahwa database ini konsisten.
Menyiapkan Fondasi Bagian #
Data latihan bagian ini adalah satu kuartal penuh aktivitas gudang Januari–Maret 2026: tujuh mutasi (tiga masuk, tiga keluar, satu pindah) yang membentuk posisi akhir kuartal. siapkan.sql kembali memasang skema dan data master, lalu menerapkan seluruh riwayat itu dalam satu transaksi — memakai pola set-based: satu perintah INSERT ... ON CONFLICT untuk semua pemasukan, satu UPDATE ... FROM untuk semua pengurangan.
-- siapkan.sql — fondasi Bagian 4: skema, data master, dan seluruh
-- riwayat aktivitas gudang satu kuartal. Bagian ini dapat dijalankan
-- sendiri, sehingga seluruh laporan di bawahnya dapat direproduksi.
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');
-- 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;
-- kondisi akhir kuartal: stok total per barang
SELECT b.kode, b.nama, b.satuan, SUM(sl.qty) AS stok_total
FROM stok_lokasi sl
JOIN barang b ON b.id = sl.barang_id
GROUP BY b.kode, b.nama, b.satuan
ORDER BY b.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 7
INSERT 0 20
INSERT 0 9
UPDATE 2
INSERT 0 2
UPDATE 7
COMMIT
kode | nama | satuan | stok_total
---------+-----------------------+--------+------------
BRG-001 | Kertas HVS A4 80gsm | box | 15
BRG-002 | Pulpen Gel Hitam | box | 8
BRG-003 | Buku Tulis 58 halaman | lusin | 20
BRG-004 | Tinta Printer Hitam | pcs | 10
BRG-005 | Sabun Cuci Cair 1L | dus | 7
BRG-006 | Deterjen Bubuk 800g | dus | 18
BRG-007 | Minyak Goreng 2L | dus | 28
BRG-008 | Beras Premium 5kg | karung | 7
BRG-009 | Kopi Sachet Instan | dus | 25
(9 rows)Posisi akhirnya: Kertas 15 box, Pulpen 8 box, Buku 20 lusin, Tinta 10 pcs, Sabun 7 dus, Deterjen 18 dus, Minyak 28 dus (terbagi dua gudang), Beras 7 karung, dan Kopi 25 dus. Lima dari sembilan barang ini akan muncul sebagai “di bawah minimum” pada laporan pertama — data yang memang sengaja dibiarkan menumpuk persoalan agar laporannya ada yang dilaporkan.
Laporan Posisi Stok #
Laporan pertama menjawab dua pertanyaan sekaligus: berapa stok tiap barang dibanding ambang amannya, dan bagaimana stok itu tersebar antarlokasi.
-- 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;Output yang Dihasilkan:
kode | nama | kategori | satuan | stok_minimum | stok_total | status
---------+-----------------------+--------------+--------+--------------+------------+------------------
BRG-001 | Kertas HVS A4 80gsm | Alat Tulis | box | 10 | 15 | aman
BRG-002 | Pulpen Gel Hitam | Alat Tulis | box | 20 | 8 | DI BAWAH MINIMUM
BRG-003 | Buku Tulis 58 halaman | Alat Tulis | lusin | 30 | 20 | DI BAWAH MINIMUM
BRG-004 | Tinta Printer Hitam | Alat Tulis | pcs | 8 | 10 | aman
BRG-005 | Sabun Cuci Cair 1L | Rumah Tangga | dus | 12 | 7 | DI BAWAH MINIMUM
BRG-006 | Deterjen Bubuk 800g | Rumah Tangga | dus | 15 | 18 | aman
BRG-007 | Minyak Goreng 2L | Pangan | dus | 25 | 28 | aman
BRG-008 | Beras Premium 5kg | Pangan | karung | 10 | 7 | DI BAWAH MINIMUM
BRG-009 | Kopi Sachet Instan | Pangan | dus | 40 | 25 | DI BAWAH MINIMUM
(9 rows)
kode | nama | gudang | rak | qty
---------+-----------------------+-----------------+----------+-----
BRG-001 | Kertas HVS A4 80gsm | Gudang Utama | Rak A-01 | 15
BRG-002 | Pulpen Gel Hitam | Gudang Utama | Rak A-01 | 8
BRG-003 | Buku Tulis 58 halaman | Gudang Utama | Rak B-02 | 20
BRG-004 | Tinta Printer Hitam | Gudang Utama | Rak A-01 | 10
BRG-005 | Sabun Cuci Cair 1L | Gudang Utama | Rak B-02 | 7
BRG-006 | Deterjen Bubuk 800g | Gudang Utama | Rak B-02 | 18
BRG-007 | Minyak Goreng 2L | Gudang Utama | Rak A-01 | 18
BRG-007 | Minyak Goreng 2L | Gudang Cadangan | Rak A-01 | 10
BRG-008 | Beras Premium 5kg | Gudang Utama | Rak C-03 | 7
BRG-009 | Kopi Sachet Instan | Gudang Cadangan | Rak A-01 | 10
BRG-009 | Kopi Sachet Instan | Gudang Cadangan | Rak B-02 | 15
(11 rows)Dua teknik kecil membuat laporan ini jujur. LEFT JOIN dari barang ke stok_lokasi menjaga barang yang stoknya nol tetap muncul — tanpa itu, barang kehabisan justru menghilang dari laporan kekurangan stok, tepat saat ia paling dibutuhkan. COALESCE(SUM(sl.qty), 0) menampilkan “0” alih-alih NULL untuk barang tanpa baris stok. Laporan kedua memperlihatkan sebaran lokasi: Minyak Goreng 18 dus di Gudang Utama dan 10 dus di Gudang Cadangan — informasi yang hanya mungkin karena sejak awal kita memilih pola junction table alih-alih satu kolom lokasi pada tabel barang.
Nilai Persediaan #
Tim keuangan menghitung persediaan dengan harga beli, bukan harga jual. Dengan harga_beli yang tersimpan di tabel barang, nilai setiap barang adalah perkalian sederhana:
-- 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;Output yang Dihasilkan:
kode | nama | kategori | stok | harga_beli | nilai_persediaan
---------+-----------------------+--------------+------+------------+------------------
BRG-007 | Minyak Goreng 2L | Pangan | 28 | 115000.00 | 3220000.00
BRG-001 | Kertas HVS A4 80gsm | Alat Tulis | 15 | 185000.00 | 2775000.00
BRG-009 | Kopi Sachet Instan | Pangan | 25 | 96000.00 | 2400000.00
BRG-008 | Beras Premium 5kg | Pangan | 7 | 320000.00 | 2240000.00
BRG-006 | Deterjen Bubuk 800g | Rumah Tangga | 18 | 72000.00 | 1296000.00
BRG-004 | Tinta Printer Hitam | Alat Tulis | 10 | 95000.00 | 950000.00
BRG-003 | Buku Tulis 58 halaman | Alat Tulis | 20 | 38500.00 | 770000.00
BRG-005 | Sabun Cuci Cair 1L | Rumah Tangga | 7 | 87000.00 | 609000.00
BRG-002 | Pulpen Gel Hitam | Alat Tulis | 8 | 42000.00 | 336000.00
(9 rows)
kategori | nilai_persediaan
--------------+------------------
Alat Tulis | 4831000.00
Pangan | 7860000.00
Rumah Tangga | 1905000.00
TOTAL SEMUA | 14596000.00
(4 rows)Query pertama memeringkatkan barang berdasarkan nilai: Minyak Goreng menempati puncak dengan Rp3.220.000, disusul Kertas dan Kopi. Query kedua merangkum ke kategori dengan ROLLUP — satu baris subtotal per kategori plus baris total keseluruhan dari klausa GROUP BY yang sama. Total persediaan CV Maju Jaya pada akhir Maret: Rp14.596.000, dengan kategori Pangan menyerap lebih dari separuhnya. Angka semacam ini bahan baku langsung laporan pajak dan perhitungan modal kerja.
Perputaran Lambat #
Stok di bawah minimum memerintahkan pembelian; stok yang diam terlalu lama justru sering memerintahkan yang sebaliknya. Laporan berikut mengukur berapa hari sejak mutasi terakhir tiap barang, lalu menandai yang berumur lebih dari 60 hari sebagai perputaran lambat:
-- 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;Output yang Dihasilkan:
kode | nama | kategori | stok | tanggal_terakhir | hari_sejak_mutasi | status_perputaran | modal_terkunci
---------+-----------------------+--------------+------+------------------+-------------------+-------------------+----------------
BRG-006 | Deterjen Bubuk 800g | Rumah Tangga | 18 | 2026-01-12 | 79 | perputaran lambat | 1296000.00
BRG-008 | Beras Premium 5kg | Pangan | 7 | 2026-01-20 | 71 | perputaran lambat | 2240000.00
BRG-007 | Minyak Goreng 2L | Pangan | 28 | 2026-02-02 | 58 | waspada | 3220000.00
BRG-004 | Tinta Printer Hitam | Alat Tulis | 10 | 2026-02-14 | 46 | waspada | 950000.00
BRG-001 | Kertas HVS A4 80gsm | Alat Tulis | 15 | 2026-03-05 | 27 | aktif | 2775000.00
BRG-003 | Buku Tulis 58 halaman | Alat Tulis | 20 | 2026-03-05 | 27 | aktif | 770000.00
BRG-005 | Sabun Cuci Cair 1L | Rumah Tangga | 7 | 2026-03-05 | 27 | aktif | 609000.00
BRG-002 | Pulpen Gel Hitam | Alat Tulis | 8 | 2026-03-18 | 14 | aktif | 336000.00
BRG-009 | Kopi Sachet Instan | Pangan | 25 | 2026-03-18 | 14 | aktif | 2400000.00
(9 rows)Deterjen (79 hari) dan Beras (71 hari) masuk kategori perputaran lambat; keduanya bersama-sama mengunci hampir Rp3,5 juta modal. Kasus Beras paling instruktif: stoknya justru di bawah minimum (7 dari 10) sehingga terlihat perlu dipesan ulang, padahal tidak ada satu pun mutasi dalam 71 hari — permintaannya yang mati, bukan pasokannya yang kurang. Kolom modal_terkunci membantu memprioritaskan: tumpukan bernilai besar yang diam lebih dulu diputuskan nasibnya (diskon, pindah kanal, atau dikembalikan ke supplier).
Rekonsiliasi: Audit Konsistensi #
Laporan terakhir tidak melapor ke manajemen, melainkan ke pemilik database sendiri. Inti rekonsiliasi: setiap baris mutasi_item menyumbang +qty di lokasi tujuan dan -qty di lokasi asal (pindah menyentuh dua lokasi, masuk satu tujuan, keluar satu asal). Jumlahkan seluruh arus itu per pasangan barang–lokasi, lalu bandingkan dengan isi tabel stok_lokasi:
-- 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;Output yang Dihasilkan:
kode | lokasi | dari_mutasi | di_stok_lokasi | selisih
---------+---------+-------------+----------------+---------
BRG-001 | G1-A-01 | 15 | 15 | 0
BRG-002 | G1-A-01 | 8 | 8 | 0
BRG-003 | G1-B-02 | 20 | 20 | 0
BRG-004 | G1-A-01 | 10 | 10 | 0
BRG-005 | G1-B-02 | 7 | 7 | 0
BRG-006 | G1-B-02 | 18 | 18 | 0
BRG-007 | G1-A-01 | 18 | 18 | 0
BRG-007 | G2-A-01 | 10 | 10 | 0
BRG-008 | G1-C-03 | 7 | 7 | 0
BRG-009 | G2-A-01 | 10 | 10 | 0
BRG-009 | G2-B-02 | 15 | 15 | 0
(11 rows)
baris_diperiksa | baris_selisih | vonis
-----------------+---------------+--------------------
11 | 0 | REKONSILIASI LULUS
(1 row)UNION ALL menggandakan tiap baris mutasi menjadi dua arus bertanda plus dan minus, FULL JOIN menjaga pasangan yang hanya ada di satu sisi (misalnya baris stok yang dibuat manual tanpa mutasi — kebocoran yang ingin kita tangkap), dan COALESCE menjaga NULL tidak memalsukan selisih. Hasilnya: 11 pasangan diperiksa, selisih nol semua, vonis REKONSILIASI LULUS. Query vonis memakai count(*) FILTER (WHERE selisih <> 0) — pola agregasi bersyarat yang dibahas chapter FILTER Clause
— sehingga laporan ini bisa dipasang sebagai pemeriksaan harian: begitu satu saja selisih muncul, vonis berubah dan alarm berbunyi.
Empat laporan menutup proyek inventaris: struktur multi-lokasi yang rapi, mutasi atomik yang teruji sampai skenario gagalnya, dan analisis yang membuat data berbicara. Pola-pola yang Anda tulis di sini — junction table, upsert agregat, transaksi dua-sisi, rekonsiliasi berbasis riwayat — adalah pola yang sama yang dipakai sistem inventaris sungguhan. Proyek berikutnya menggeser sudut pandang dari gudang ke kasir: database penjualan ritel, tempat stok berkurang karena pelanggan membeli, dan analisis bergeser dari persediaan ke pelanggan, keranjang belanja, dan margin.
Referensi #
- Aggregate Functions
—
SUM,COALESCEbersama agregat, dan klausaFILTER. - SELECT
— sintaks
GROUP BY ROLLUP,FULL JOIN, danORDER BY ... NULLS LAST. - Table Expressions
— CTE
WITHdan semantikGROUP BYpada query bertingkat.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
laporan-stok.sql
Unduh berkas-- 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;
nilai-persediaan.sql
Unduh berkas-- 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;
perputaran-lambat.sql
Unduh berkas-- 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;
rekonsiliasi.sql
Unduh berkas-- 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;
siapkan.sql
Unduh berkas-- siapkan.sql — fondasi Bagian 4: skema, data master, dan seluruh
-- riwayat aktivitas gudang satu kuartal. Bagian ini dapat dijalankan
-- sendiri, sehingga seluruh laporan di bawahnya dapat direproduksi.
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');
-- 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;
-- kondisi akhir kuartal: stok total per barang
SELECT b.kode, b.nama, b.satuan, SUM(sl.qty) AS stok_total
FROM stok_lokasi sl
JOIN barang b ON b.id = sl.barang_id
GROUP BY b.kode, b.nama, b.satuan
ORDER BY b.kode;