SQL untuk Analisis Stok: Saldo Berjalan dan Kartu Stok Otomatis
Blog/Tutorial SQL/SQL untuk Analisis Stok: Saldo Berjalan dan Kartu Stok Otomatis

SQL untuk Analisis Stok: Saldo Berjalan dan Kartu Stok Otomatis

BimaBima
·12 Oktober 2025·12 menit baca

Penulis

Bima

Bima

Founder & Data Professional

Bagikan

TL;DR

Saldo berjalan di SQL dibikin pakai SUM dengan klausa OVER, dipartisi per kode produk dan diurutkan per tanggal. Bentuk dasarnya SUM(qty_masuk - qty_keluar) OVER (PARTITION BY kode_produk ORDER BY tanggal, id ROWS UNBOUNDED PRECEDING). Kunci supaya hasilnya nggak acak adalah nambahin kolom kedua di ORDER BY untuk transaksi yang tanggalnya sama. Dari saldo berjalan ini kamu bisa lanjut nangkap stok minus, ngitung stok akhir per gudang, dan nyari barang yang nggak gerak.

Saldo berjalan di SQL dibikin pakai satu window function: SUM(qty_masuk - qty_keluar) OVER (PARTITION BY kode_produk ORDER BY tanggal, id ROWS UNBOUNDED PRECEDING).

Satu baris itu ngerjain apa yang di Excel butuh kolom bantu, dan hasilnya nggak rusak waktu data nambah.

Di bawah ini aku bahas query kartu stok lengkapnya, kesalahan yang bikin hasilnya acak, dan tiga turunan yang paling sering diminta: deteksi stok minus, stok akhir per gudang, dan barang yang nggak gerak.

Struktur tabel yang dipakai

Semua contoh di sini pakai satu tabel mutasi_stok dari dataset ngulikdata. Isinya catatan barang masuk dan keluar di 3 gudang toko_berkah.

CREATE TABLE mutasi_stok (
  id            SERIAL PRIMARY KEY,
  tanggal       DATE NOT NULL,
  kode_produk   VARCHAR(20) NOT NULL,
  kode_gudang   VARCHAR(10) NOT NULL,
  keterangan    VARCHAR(50),
  qty_masuk     INTEGER DEFAULT 0,
  qty_keluar    INTEGER DEFAULT 0,
  harga_satuan  NUMERIC(12,2)
);

Contoh isinya:

idtanggalkode_produkketeranganqty_masukqty_keluar
12025-09-01BRG-014Stok awal1200
22025-09-03BRG-014Pembelian2400
32025-09-03BRG-014Penjualan085
42025-09-05BRG-014Penjualan0140
52025-09-08BRG-014Retur pelanggan120

Satu aturan penting soal desain tabel ini: jangan simpan kolom saldo. Saldo itu hasil hitungan, dan kalau disimpan, dia bakal nggak sinkron begitu ada transaksi yang diedit atau disisipkan mundur.

Query saldo berjalan yang paling dasar

Ini bentuk intinya.

SELECT
  tanggal,
  kode_produk,
  keterangan,
  qty_masuk,
  qty_keluar,
  SUM(qty_masuk - qty_keluar) OVER (
    PARTITION BY kode_produk
    ORDER BY tanggal, id
    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
  ) AS saldo
FROM mutasi_stok
WHERE kode_produk = 'BRG-014'
ORDER BY tanggal, id;

Hasilnya:

tanggalketeranganmasukkeluarsaldo
2025-09-01Stok awal1200120
2025-09-03Pembelian2400360
2025-09-03Penjualan085275
2025-09-05Penjualan0140135
2025-09-08Retur pelanggan120147

Sekarang aku uraikan tiap bagiannya.

SUM(qty_masuk - qty_keluar) ngitung perubahan bersih per baris. Masuk nambah, keluar ngurangi.

PARTITION BY kode_produk bikin hitungan mulai dari nol lagi tiap ganti produk. Tanpa ini, saldo produk kedua bakal nerusin saldo produk pertama.

ORDER BY tanggal, id nentuin urutan penjumlahan. Bagian ini yang paling sering salah, dan aku bahas khusus di bawah.

ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW nentuin jendela hitungan: dari baris paling awal sampai baris yang lagi diproses. Ini yang bikin sifatnya jadi berjalan, bukan total.

Penjelasan resmi tiap klausa ada di dokumentasi PostgreSQL soal window function.

Kesalahan nomor satu: ORDER BY cuma pakai tanggal

Ini penyebab "query-nya bener tapi hasilnya beda tiap dijalanin".

Lihat data di atas. Ada dua transaksi di tanggal 2025-09-03: pembelian 240 dan penjualan 85.

Kalau kamu tulis ORDER BY tanggal doang, database bebas milih mana yang duluan. Hasil saldonya bisa jadi begini:

-- urutan A
120 -> 360 -> 275

-- urutan B
120 ->  35 -> 275

Baris terakhir sama, tapi baris tengahnya beda. Dan urutan B nunjukin saldo 35, yang bikin orang gudang panik padahal barangnya ada.

Perbaikannya satu kolom: tambahin id atau nomor dokumen sebagai pengurut kedua.

ORDER BY tanggal, id

Kalau tabelmu punya kolom waktu lengkap, pakai itu. Kalau nggak, id yang naik terus adalah pengganti yang aman.

Kesalahan nomor dua: pakai RANGE, bukan ROWS

Kalau kamu nulis window function tanpa nyebut ROWS atau RANGE, sebagian besar database pakai RANGE sebagai bawaan.

Bedanya kelihatan waktu ada beberapa baris dengan nilai ORDER BY yang sama.

ROWS ngitung per baris. Baris ketiga dapat jumlah dari baris 1 sampai 3.

RANGE ngitung per nilai. Semua baris dengan tanggal yang sama dianggap satu kelompok, jadi dua transaksi di 2025-09-03 dapat saldo yang sama, yaitu 275.

Buat kartu stok, itu salah. Kamu perlu lihat saldo setelah tiap transaksi, bukan setelah tiap hari.

Aturan praktisnya: buat saldo berjalan, selalu tulis ROWS secara eksplisit.

Query kartu stok lengkap dengan saldo awal dan akhir

Kartu stok yang dipakai orang gudang biasanya butuh saldo sebelum dan sesudah tiap baris.

SELECT
  tanggal,
  keterangan,
  qty_masuk,
  qty_keluar,
  SUM(qty_masuk - qty_keluar) OVER w
    - (qty_masuk - qty_keluar) AS saldo_awal,
  SUM(qty_masuk - qty_keluar) OVER w AS saldo_akhir
FROM mutasi_stok
WHERE kode_produk = 'BRG-014'
  AND kode_gudang = 'GD-01'
WINDOW w AS (
  PARTITION BY kode_produk, kode_gudang
  ORDER BY tanggal, id
  ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
)
ORDER BY tanggal, id;

Klausa WINDOW bikin kamu nulis definisi jendela sekali, lalu dipanggil pakai nama. Berguna waktu kamu butuh tiga atau empat kolom yang pakai jendela yang sama.

Perhatiin PARTITION BY kode_produk, kode_gudang. Kalau barang yang sama ada di tiga gudang, saldo harus dihitung terpisah per gudang. Ini salah satu yang sering kelewat.

Struktur kolom kartu stoknya sendiri aku bahas dari sisi Excel di kartu stok barang.

Nangkap stok minus

Saldo negatif artinya ada barang keluar yang nggak pernah tercatat masuk. Penyebab paling sering: nota pembelian belum diinput, atau satuan ketuker antara dus dan pcs.

WITH kartu AS (
  SELECT
    id,
    tanggal,
    kode_produk,
    kode_gudang,
    qty_masuk,
    qty_keluar,
    SUM(qty_masuk - qty_keluar) OVER (
      PARTITION BY kode_produk, kode_gudang
      ORDER BY tanggal, id
      ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS saldo
  FROM mutasi_stok
)
SELECT *
FROM kartu
WHERE saldo < 0
ORDER BY kode_produk, tanggal;

Yang penting di sini: saldo minus perlu dicek di titik kejadiannya, bukan cuma di akhir periode. Barang bisa minus tanggal 5 lalu balik positif tanggal 7 waktu nota telatnya masuk. Saldo akhir kelihatan normal, padahal ada masalah pencatatan.

Di dataset toko_berkah, query ini nemu 47 kejadian saldo minus dalam 6 bulan, tersebar di 19 kode produk. Dari 47 itu, 31 balik positif dalam 3 hari. Pola itu nunjukin masalahnya di kecepatan input nota, bukan barang hilang.

Stok akhir per produk per gudang

Buat laporan posisi stok terkini, kamu butuh baris terakhir dari tiap kelompok.

Di PostgreSQL, cara paling ringkas pakai DISTINCT ON:

SELECT DISTINCT ON (kode_produk, kode_gudang)
  kode_produk,
  kode_gudang,
  tanggal,
  saldo
FROM (
  SELECT
    kode_produk,
    kode_gudang,
    tanggal,
    id,
    SUM(qty_masuk - qty_keluar) OVER (
      PARTITION BY kode_produk, kode_gudang
      ORDER BY tanggal, id
      ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS saldo
  FROM mutasi_stok
) k
ORDER BY kode_produk, kode_gudang, tanggal DESC, id DESC;

Di database lain yang nggak punya DISTINCT ON, pakai ROW_NUMBER:

WITH kartu AS (
  SELECT
    kode_produk,
    kode_gudang,
    tanggal,
    SUM(qty_masuk - qty_keluar) OVER (
      PARTITION BY kode_produk, kode_gudang
      ORDER BY tanggal, id
      ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS saldo,
    ROW_NUMBER() OVER (
      PARTITION BY kode_produk, kode_gudang
      ORDER BY tanggal DESC, id DESC
    ) AS urutan
  FROM mutasi_stok
)
SELECT kode_produk, kode_gudang, tanggal, saldo
FROM kartu
WHERE urutan = 1;

Sebenernya buat stok akhir doang, kamu nggak perlu window function sama sekali. SELECT kode_produk, SUM(qty_masuk - qty_keluar) FROM mutasi_stok GROUP BY kode_produk udah cukup dan jauh lebih cepat.

Window function baru kepakai kalau kamu butuh saldo di tiap titik waktu, bukan cuma di akhir. Ini pembeda yang sering bikin query jadi berat tanpa perlu.

Nyari barang yang nggak gerak

Barang mati itu uang yang nganggur di rak. Query ini nyari produk yang nggak pernah keluar lebih dari 90 hari.

SELECT
  kode_produk,
  MAX(tanggal) FILTER (WHERE qty_keluar > 0) AS keluar_terakhir,
  CURRENT_DATE - MAX(tanggal) FILTER (WHERE qty_keluar > 0) AS umur_hari
FROM mutasi_stok
GROUP BY kode_produk
HAVING CURRENT_DATE - MAX(tanggal) FILTER (WHERE qty_keluar > 0) > 90
ORDER BY umur_hari DESC;

Klausa FILTER cuma ada di PostgreSQL. Di MySQL atau SQL Server, ganti pakai CASE:

MAX(CASE WHEN qty_keluar > 0 THEN tanggal END) AS keluar_terakhir

Di dataset toko_berkah, query ini nemu 23 kode produk yang nggak keluar lebih dari 90 hari. Nilai persediaannya total sekitar Rp 41,2 juta, atau 6,8 persen dari nilai stok gudang.

Yang bikin kaget: 9 dari 23 produk itu tetap dibeli ulang di periode yang sama. Barang nggak laku, tapi tetap dipesan karena masuk daftar pembelian rutin.

Temuan kayak gini yang bikin query stok berguna buat orang di luar tim data. Konsep ngelompokin barang berdasarkan perilakunya ada di glosarium segmentation.

Ngecek selisih sama stok fisik

Kalau kamu punya tabel hasil stock opname, gabungkan buat nyari selisih.

WITH sistem AS (
  SELECT
    kode_produk,
    kode_gudang,
    SUM(qty_masuk - qty_keluar) AS saldo_sistem
  FROM mutasi_stok
  WHERE tanggal <= '2025-09-30'
  GROUP BY kode_produk, kode_gudang
)
SELECT
  s.kode_produk,
  s.kode_gudang,
  s.saldo_sistem,
  o.qty_fisik,
  s.saldo_sistem - o.qty_fisik AS selisih,
  (s.saldo_sistem - o.qty_fisik) * p.harga_beli AS nilai_selisih
FROM sistem s
LEFT JOIN opname o
  ON o.kode_produk = s.kode_produk
 AND o.kode_gudang = s.kode_gudang
 AND o.tanggal = '2025-09-30'
LEFT JOIN produk p
  ON p.kode_produk = s.kode_produk
WHERE s.saldo_sistem <> COALESCE(o.qty_fisik, 0)
ORDER BY ABS((s.saldo_sistem - o.qty_fisik) * p.harga_beli) DESC;

Urutkan berdasarkan nilai rupiah, bukan jumlah unit. Selisih 200 pcs permen jauh lebih murah dari selisih 3 unit blender.

Kesalahan umum lainnya

Nyimpan kolom saldo di tabel transaksi. Begitu ada transaksi yang disisipkan mundur atau dihapus, semua saldo setelahnya salah. Hitung pakai query.

Lupa partisi per gudang. Kalau kode produk yang sama ada di beberapa gudang dan kamu cuma partisi per produk, saldonya campur aduk.

Nyampur satuan. Kalau pembelian dicatat dalam dus dan penjualan dalam pcs, saldo berjalan jadi omong kosong. Samakan satuan sebelum masuk tabel, atau simpan kolom faktor konversi.

Nggak ngasih indeks. Query saldo berjalan baca banyak baris. Bikin indeks gabungan di (kode_produk, kode_gudang, tanggal, id). Di tabel 2 juta baris, ini bisa motong waktu query dari belasan detik jadi bawah satu detik.

Nganggap saldo minus pasti pencurian. Dari 47 kejadian di contoh tadi, 31 cuma soal nota telat masuk. Cek urutan waktu dulu sebelum bikin tuduhan.

Nyaring dengan WHERE sebelum window function jalan. Kalau kamu tulis WHERE tanggal >= '2025-09-01' di query yang sama dengan window function, saldo awalnya jadi nol, bukan sisa dari bulan sebelumnya. Hitung dulu di CTE, baru saring di luar.

FAQ

Kenapa saldo berjalan saya hasilnya acak padahal query-nya benar?

Hampir pasti karena ORDER BY di dalam OVER cuma pakai kolom tanggal. Kalau ada beberapa transaksi di tanggal yang sama, database bebas nentuin urutannya, dan hasilnya bisa beda tiap query dijalankan. Tambahin kolom kedua yang unik, biasanya id atau nomor dokumen, jadi ORDER BY tanggal, id. Ini perbaikan satu baris yang nyelesein sebagian besar kasus.

Apa bedanya ROWS dan RANGE di window function?

ROWS ngitung berdasarkan jumlah baris, RANGE ngitung berdasarkan nilai di kolom ORDER BY. Buat saldo stok, pakai ROWS. Kalau kamu pakai RANGE dan ada beberapa transaksi di tanggal yang sama, semua baris tanggal itu dianggap satu kelompok dan dapat nilai saldo yang sama. Kartu stokmu jadi salah di hari yang ramai.

Window function jalan di MySQL dan SQL Server?

Jalan. MySQL mendukungnya mulai versi 8.0, SQL Server sejak 2012, PostgreSQL sejak 8.4, dan SQLite sejak 3.25. Sintaks dasarnya sama persis. Yang beda cuma fungsi tambahan seperti FILTER yang cuma ada di PostgreSQL, dan itu bisa diganti pakai CASE WHEN di database lain.

Lebih baik simpan kolom saldo di tabel atau hitung pakai query?

Hitung pakai query buat data di bawah beberapa juta baris, karena saldo yang disimpan gampang nggak sinkron waktu ada transaksi yang diedit atau dihapus. Kalau tabelnya udah puluhan juta baris dan query-nya sering dipanggil, simpan hasil hitungan harian di tabel ringkasan, lalu hitung saldo berjalan cuma untuk transaksi setelah tanggal ringkasan terakhir.

Gimana nangkap stok yang selisih sama hitungan fisik?

Bikin dulu saldo sistem per produk per gudang pakai query saldo berjalan. Lalu gabungkan dengan tabel hasil stock opname pakai LEFT JOIN pada kode produk dan gudang. Kolom selisihnya saldo sistem dikurangi qty fisik. Urutkan berdasarkan nilai rupiah selisih, bukan jumlah unit, supaya kamu periksa yang paling merugikan duluan.

Penutup

Tiga hal yang perlu kamu bawa.

Saldo berjalan cuma butuh satu window function, dan bentuk dasarnya sama di semua database besar. Selalu tambahin kolom kedua di ORDER BY supaya urutannya pasti, dan selalu tulis ROWS secara eksplisit. Jangan simpan kolom saldo di tabel transaksi, karena dia bakal nggak sinkron cepat atau lambat.

Coba jalanin query pertama di data stokmu sendiri buat satu kode produk yang kamu hafal riwayatnya. Kalau angkanya cocok sama ingatanmu, query-nya siap dipakai buat semua produk.

Lanjut baca laporan stok barang buat nyusun keluarannya jadi laporan yang kebaca, atau rumus Excel untuk stok barang kalau datamu masih di spreadsheet dan belum masuk database.

Coba Langsung

Mau praktek langsung? Mulai latihan SQL gratis

Latihan interaktif, langsung di browser.

Buka NgulikSQL →
Bagikan:
Bima
Ditulis oleh

Bima

Founder & Data Professional

Founder Ngulik Data. Passionate about making data analysis accessible for everyone.

Artikel terkait

Cara Membuat Pivot Dinamis di SQL (Panduan 2026)
Tutorial SQL
19 Juli 2026•10 menit baca

Cara Membuat Pivot Dinamis di SQL (Panduan 2026)

Pivot dinamis di SQL ngubah baris jadi kolom tanpa kamu hardcode nama kolomnya. Ini cara bikinnya pakai CASE WHEN dan versi yang kolomnya ngikut data.

BimaBima
Menghitung YTD, QTD, dan MTD di SQL (Panduan 2026)
Tutorial SQL
17 Juli 2026•9 menit baca

Menghitung YTD, QTD, dan MTD di SQL (Panduan 2026)

YTD, QTD, dan MTD ngukur total dari awal tahun, kuartal, atau bulan sampai hari ini. Ini cara ngitungnya di SQL pakai DATE_TRUNC, plus satu query gabungan.

BimaBima
Gap and Island Analysis di SQL
Tutorial SQL
15 Juli 2026•10 menit baca

Gap and Island Analysis di SQL

Gap and island analysis di SQL nyari deret data yang berturut-turut (island) dan celah yang bolong (gap). Cocok buat hitung streak login atau cari tanggal transaksi yang hilang.

BimaBima
Kembali ke Blog
Ngulik Data logoNgulik Data

Platform edukasi data lengkap untuk professionals Indonesia. Belajar SQL, Data Analysis, dan lebih banyak lagi dengan praktek langsung dan feedback real-time.

© 2026 Ngulik Data. Semua hak dilindungi.

TAUTAN
BantuanHargaDatasetBlogAfiliasi
LEGAL
Syarat & KetentuanKebijakan Privasi
Ngulik Data
DatasetLeaderboardBlogStore