Pemilik toko jarang membuka database hanya untuk melihat satu produk — yang dibutuhkan adalah gambaran besar: berapa nilai uang yang tertanam di persediaan, produk mana yang segera perlu dipesan ulang, dan seberapa ramai pergerakan barang dari bulan ke bulan. Ketiga pertanyaan itu dijawab aggregation pipeline pada bagian penutup ini. Setelah laporan berjalan, kita menuntaskan proyek dengan suite tes yang memverifikasi seluruh perilaku sejak Bagian 1.

Tiga Laporan dari Satu Collection #

Semua laporan menyasar collection produk yang sama dengan sudut pandang berbeda:

LaporanBentuk pipelineHasil
Nilai persediaan$match → $group per kategori → $sortTotal harga × stok produk aktif
Stok menipis$match (stok < 10) → $sort → $projectDaftar produk yang perlu dipesan ulang
Riwayat per bulan$unwind → $group per bulan → $sortTotal unit masuk dan keluar tiap bulan

Skrip Laporan #

js
// inventaris-laporan.js — laporan persediaan dengan aggregation pipeline
// Jalankan setelah inventaris-index.js:
//   mongosh "mongodb://127.0.0.1:27017/?directConnection=true" --file src/inventaris-laporan.js

var inv = db.getSiblingDB("inventaris")

function nilaiPersediaan() {
  print("== nilai persediaan per kategori ==")
  inv.produk.aggregate([
    { $match: { aktif: true } },
    { $group: {
        _id: "$kategori",
        jumlahProduk: { $sum: 1 },
        nilai: { $sum: { $multiply: ["$harga", "$stok"] } },
    } },
    { $sort: { nilai: -1 } },
  ]).forEach(function (r) {
    print(" ", r._id, "| produk", r.jumlahProduk, "| nilai", r.nilai)
  })
}

function stokMenipis(batas) {
  print("== produk dengan stok di bawah", batas, "==")
  inv.produk.aggregate([
    { $match: { aktif: true, stok: { $lt: batas } } },
    { $sort: { stok: 1, sku: 1 } },
    { $project: { _id: 0, sku: 1, nama: 1, stok: 1 } },
  ]).forEach(function (r) {
    print(" ", r.sku, "|", r.nama, "| stok", r.stok)
  })
}

function riwayatPerBulan() {
  print("== riwayat stok per bulan ==")
  inv.produk.aggregate([
    { $unwind: "$riwayat_stok" },
    { $group: {
        _id: {
          bulan: { $dateToString: { format: "%Y-%m", date: "$riwayat_stok.tanggal" } },
        },
        masuk: { $sum: {
          $cond: [{ $eq: ["$riwayat_stok.jenis", "masuk"] }, "$riwayat_stok.jumlah", 0],
        } },
        keluar: { $sum: {
          $cond: [{ $eq: ["$riwayat_stok.jenis", "keluar"] }, "$riwayat_stok.jumlah", 0],
        } },
    } },
    { $sort: { "_id.bulan": 1 } },
  ]).forEach(function (r) {
    print(" ", r._id.bulan, "| masuk", r.masuk, "| keluar", r.keluar)
  })
}

nilaiPersediaan()
stokMenipis(10)
riwayatPerBulan()

Jalankan setelah seluruh skrip sebelumnya:

bash
mongosh "mongodb://127.0.0.1:27017/?directConnection=true" --file src/inventaris-laporan.js

Output yang Dihasilkan:

text
== nilai persediaan per kategori ==
  monitor | produk 2 | nilai 50800000
  keyboard | produk 3 | nilai 28100000
  mouse | produk 2 | nilai 10970000
  aksesori | produk 2 | nilai 10380000
== produk dengan stok di bawah 10 ==
  MS-002 | Mouse Gaming 8 Tombol | stok 4
  KB-003 | Keyboard Split Ergonomis | stok 8
  MN-001 | Monitor IPS 24 inci | stok 8
  AK-002 | Dock USB-C 6-in-1 | stok 9
== riwayat stok per bulan ==
  2026-01 | masuk 192 | keluar 0
  2026-02 | masuk 0 | keluar 18
  2026-03 | masuk 0 | keluar 6
  2026-04 | masuk 0 | keluar 25
  2026-05 | masuk 15 | keluar 2

Bedah Tiga Pipeline #

  1. Nilai persediaan menyaring produk aktif lebih dulu ($match) agar KB-002 yang dinonaktifkan lalu dihapus tidak ikut dihitung, kemudian $group menjumlahkan $multiply: ["$harga", "$stok"] per kategori. Monitor menempati posisi teratas dengan Rp50.800.000 — dua produk saja menyerap lebih dari separuh nilai persediaan.
  2. Stok menipis memakai $match gabungan: aktif: true dan stok: { $lt: 10 }. $sort ganda pada stok lalu sku membuat urutan hasil stabil meskipun dua produk sama-sama berstok 8. KB-004 tidak muncul karena batasnya ketat — stok 10 tidak termasuk di bawah 10.
  3. Riwayat per bulan membongkar array dengan $unwind: setiap entri riwayat_stok menjadi satu document tersendiri, lalu $group menjangkarkan bulannya lewat $dateToString format %Y-%m. Dua accumulator $sum dengan $cond memilah arah pergerakan — entri masuk dijumlahkan ke kolom masuk, entri lain bernilai 0. Bulan Januari tercatat 192 unit masuk, sedangkan Mei muncul dari transaksi demo Bagian 2.

Angka rekap ini juga menyimpan pelajaran desain: riwayat KB-002 (60 unit masuk, 15 keluar) tidak lagi terhitung karena terhapus bersama document produknya — konsekuensi embedding yang harus kita sadari. Bila audit lintas produk menjadi kebutuhan utama, transaksi layak dipindah ke collection terpisah.

Suite Tes Proyek #

Empat bagian proyek kini lengkap, dan saatnya memastikan semuanya tetap benar. Suite tes berikut bersifat mandiri: data uji dibuat sendiri di database inventaris_tes, tidak bergantung pada skrip src/, dan membersihkan dirinya setelah selesai:

js
// tests/semua-tes.js — suite tes mandiri proyek manajemen inventaris
// Tes ini tidak bergantung pada src/: data uji dibuat sendiri lalu dibersihkan.
// Jalankan dari folder proyek:
//   mongosh "mongodb://127.0.0.1:27017/?directConnection=true" --file tests/semua-tes.js

var tes = db.getSiblingDB("inventaris_tes")

function assertSama(nyata, harapan, pesan) {
  const a = EJSON.stringify(nyata)
  const h = EJSON.stringify(harapan)
  if (a !== h) {
    throw new Error("GAGAL - " + pesan + " | harapan: " + h + " | nyata: " + a)
  }
  print("lulus -", pesan)
}

function siapkanData() {
  tes.dropDatabase()
  tes.produk.insertMany([
    {
      sku: "KB-101", nama: "Keyboard Uji", kategori: "keyboard", harga: 100000,
      stok: 10, aktif: true, pemasok: { nama: "PT Uji", email: "[email protected]", kota: "Bandung" },
      riwayat_stok: [
        { tanggal: ISODate("2026-01-05T00:00:00Z"), jenis: "masuk", jumlah: 12 },
        { tanggal: ISODate("2026-02-01T00:00:00Z"), jenis: "keluar", jumlah: 2 },
      ],
    },
    {
      sku: "MN-101", nama: "Monitor Uji", kategori: "monitor", harga: 2000000,
      stok: 4, aktif: true, pemasok: { nama: "PT Uji", email: "[email protected]", kota: "Bandung" },
      riwayat_stok: [
        { tanggal: ISODate("2026-01-06T00:00:00Z"), jenis: "masuk", jumlah: 6 },
        { tanggal: ISODate("2026-02-02T00:00:00Z"), jenis: "keluar", jumlah: 2 },
      ],
    },
    {
      sku: "MS-101", nama: "Mouse Uji", kategori: "mouse", harga: 200000,
      stok: 9, aktif: true, pemasok: { nama: "PT Uji", email: "[email protected]", kota: "Bandung" },
      riwayat_stok: [
        { tanggal: ISODate("2026-01-07T00:00:00Z"), jenis: "masuk", jumlah: 10 },
        { tanggal: ISODate("2026-02-03T00:00:00Z"), jenis: "keluar", jumlah: 1 },
      ],
    },
    {
      sku: "AK-101", nama: "Aksesori Uji", kategori: "aksesori", harga: 50000,
      stok: 20, aktif: true, pemasok: { nama: "PT Uji", email: "[email protected]", kota: "Bandung" },
      riwayat_stok: [
        { tanggal: ISODate("2026-01-08T00:00:00Z"), jenis: "masuk", jumlah: 25 },
        { tanggal: ISODate("2026-02-04T00:00:00Z"), jenis: "keluar", jumlah: 5 },
      ],
    },
    {
      sku: "KB-102", nama: "Keyboard Kedua", kategori: "keyboard", harga: 300000,
      stok: 3, aktif: true, pemasok: { nama: "PT Uji", email: "[email protected]", kota: "Bandung" },
      riwayat_stok: [
        { tanggal: ISODate("2026-01-09T00:00:00Z"), jenis: "masuk", jumlah: 5 },
        { tanggal: ISODate("2026-02-05T00:00:00Z"), jenis: "keluar", jumlah: 2 },
      ],
    },
  ])
}

function tesCrud() {
  assertSama(tes.produk.countDocuments({}), 5, "seed memuat 5 produk")

  tes.produk.insertOne({
    sku: "AK-102", nama: "Dock Uji", kategori: "aksesori", harga: 400000,
    stok: 7, aktif: true, pemasok: { nama: "PT Uji", email: "[email protected]", kota: "Bandung" },
    riwayat_stok: [{ tanggal: ISODate("2026-02-10T00:00:00Z"), jenis: "masuk", jumlah: 7 }],
  })
  assertSama(tes.produk.findOne({ sku: "AK-102" }).nama, "Dock Uji", "produk baru tersimpan")

  tes.produk.updateOne(
    { sku: "KB-101" },
    {
      $inc: { stok: 5 },
      $push: {
        riwayat_stok: {
          $each: [{ tanggal: ISODate("2026-02-20T00:00:00Z"), jenis: "masuk", jumlah: 5 }],
          $slice: -20,
        },
      },
    }
  )
  const kb101 = tes.produk.findOne({ sku: "KB-101" })
  assertSama(kb101.stok, 15, "$inc menambah stok KB-101 menjadi 15")
  assertSama(kb101.riwayat_stok.length, 3, "$push menambah entri riwayat")

  tes.produk.updateOne(
    { sku: "MN-101" },
    {
      $inc: { stok: -2 },
      $push: {
        riwayat_stok: {
          $each: [{ tanggal: ISODate("2026-02-20T00:00:00Z"), jenis: "keluar", jumlah: 2 }],
          $slice: -20,
        },
      },
    }
  )
  assertSama(tes.produk.findOne({ sku: "MN-101" }).stok, 2, "$inc mengurangi stok MN-101 menjadi 2")

  tes.produk.updateOne({ sku: "KB-102" }, { $set: { aktif: false } })
  assertSama(tes.produk.findOne({ sku: "KB-102" }).aktif, false, "produk bisa dinonaktifkan")

  const hapus = tes.produk.deleteOne({ sku: "KB-102", aktif: false })
  assertSama(hapus.deletedCount, 1, "produk nonaktif terhapus")
  assertSama(tes.produk.countDocuments({}), 5, "jumlah produk kembali 5 setelah hapus")
}

function tesIndex() {
  tes.produk.createIndex({ sku: 1 }, { unique: true })
  tes.produk.createIndex({ kategori: 1, harga: 1 })
  tes.produk.createIndex({ nama: "text" })
  assertSama(
    tes.produk.getIndexes().map(function (i) { return i.name }),
    ["_id_", "sku_1", "kategori_1_harga_1", "nama_text"],
    "tiga index dibuat sesuai rancangan"
  )

  let kode = null
  try {
    tes.produk.insertOne({ sku: "KB-101", nama: "duplikat" })
  } catch (galat) {
    kode = galat.code
  }
  assertSama(kode, 11000, "duplikat sku ditolak index unik (E11000)")
  assertSama(tes.produk.countDocuments({}), 5, "duplikat tidak tersimpan")

  const rentang = tes.produk
    .find({ kategori: "aksesori", harga: { $gte: 0, $lte: 500000 } })
    .sort({ harga: 1 })
    .projection({ _id: 0, sku: 1 })
    .toArray()
    .map(function (p) { return p.sku })
  assertSama(rentang, ["AK-101", "AK-102"], "query kategori + rentang harga terurut")

  const teks = tes.produk
    .find({ $text: { $search: "monitor" } }, { _id: 0, sku: 1 })
    .toArray()
    .map(function (p) { return p.sku })
  assertSama(teks, ["MN-101"], "pencarian teks menemukan Monitor Uji")
}

function tesLaporan() {
  const nilai = tes.produk.aggregate([
    { $match: { aktif: true } },
    { $group: {
        _id: "$kategori",
        nilai: { $sum: { $multiply: ["$harga", "$stok"] } },
    } },
    { $sort: { nilai: -1 } },
  ]).toArray().map(function (r) { return [r._id, r.nilai] })
  assertSama(nilai, [
    ["monitor", 4000000],
    ["aksesori", 3800000],
    ["mouse", 1800000],
    ["keyboard", 1500000],
  ], "nilai persediaan per kategori benar")

  const menipis = tes.produk.aggregate([
    { $match: { aktif: true, stok: { $lt: 10 } } },
    { $sort: { stok: 1, sku: 1 } },
    { $project: { _id: 0, sku: 1, stok: 1 } },
  ]).toArray().map(function (r) { return [r.sku, r.stok] })
  assertSama(menipis, [["MN-101", 2], ["AK-102", 7], ["MS-101", 9]], "produk stok menipis terdeteksi")

  const bulanan = tes.produk.aggregate([
    { $unwind: "$riwayat_stok" },
    { $group: {
        _id: { bulan: { $dateToString: { format: "%Y-%m", date: "$riwayat_stok.tanggal" } } },
        masuk: { $sum: {
          $cond: [{ $eq: ["$riwayat_stok.jenis", "masuk"] }, "$riwayat_stok.jumlah", 0],
        } },
        keluar: { $sum: {
          $cond: [{ $eq: ["$riwayat_stok.jenis", "keluar"] }, "$riwayat_stok.jumlah", 0],
        } },
    } },
    { $sort: { "_id.bulan": 1 } },
  ]).toArray().map(function (r) { return [r._id.bulan, r.masuk, r.keluar] })
  assertSama(bulanan, [["2026-01", 53, 0], ["2026-02", 12, 12]], "riwayat per bulan benar")

  const konsisten = tes.produk.find({}).toArray().every(function (p) {
    const masuk = p.riwayat_stok
      .filter(function (r) { return r.jenis === "masuk" })
      .reduce(function (a, r) { return a + r.jumlah }, 0)
    const keluar = p.riwayat_stok
      .filter(function (r) { return r.jenis === "keluar" })
      .reduce(function (a, r) { return a + r.jumlah }, 0)
    return masuk - keluar === p.stok
  })
  assertSama(konsisten, true, "stok konsisten dengan jumlah riwayat (semua produk)")
}

siapkanData()
tesCrud()
tesIndex()
tesLaporan()
print("SEMUA TES LULUS")
tes.dropDatabase()

Jalankan dari folder proyek:

bash
mongosh "mongodb://127.0.0.1:27017/?directConnection=true" --file tests/semua-tes.js

Output yang Dihasilkan:

text
lulus - seed memuat 5 produk
lulus - produk baru tersimpan
lulus - $inc menambah stok KB-101 menjadi 15
lulus - $push menambah entri riwayat
lulus - $inc mengurangi stok MN-101 menjadi 2
lulus - produk bisa dinonaktifkan
lulus - produk nonaktif terhapus
lulus - jumlah produk kembali 5 setelah hapus
lulus - tiga index dibuat sesuai rancangan
lulus - duplikat sku ditolak index unik (E11000)
lulus - duplikat tidak tersimpan
lulus - query kategori + rentang harga terurut
lulus - pencarian teks menemukan Monitor Uji
lulus - nilai persediaan per kategori benar
lulus - produk stok menipis terdeteksi
lulus - riwayat per bulan benar
lulus - stok konsisten dengan jumlah riwayat (semua produk)
SEMUA TES LULUS

Tiga hal membuat suite ini berharga. Pertama, fungsi assertSama() membandingkan nilai nyata dan harapan lewat EJSON.stringify() lalu melempar Error saat berbeda — satu assertion gagal, seluruh skrip berhenti, tidak ada laporan palsu. Kedua, angka harapan ditulis manual (misalnya nilai persediaan monitor 4000000), sehingga perubahan perilaku MongoDB akan tertangkap. Ketiga, assertion terakhir menguji invarian proyek: untuk setiap produk, jumlah masuk dikurangi keluar pada riwayat harus sama persis dengan field stok — kontrak yang dijaga semua fungsi CRUD sejak Bagian 2.

Apa Berikutnya #

Proyek manajemen inventaris tuntas: schema sadar pola akses, CRUD atomik, index teruji lewat explain, laporan aggregation, dan suite tes yang lulus penuh. Selanjutnya, Proyek Blog dengan MongoDB mengangkat tantangan yang berbeda — relasi antar-collection, pola subset untuk comments, dan pagination yang stabil saat data terus bertambah.