Laporan omzet, produk, dan tren pada Bagian 2 masing-masing menjawab satu pertanyaan dengan satu pipeline. Dashboard yang sesungguhnya bekerja sebaliknya: satu tampilan, banyak angka, dimuat serentak. Bagian penutup ini merakit ketiga laporan menjadi dashboard satu pipeline dengan $facet, menyimpan hasilnya sebagai collection laporan_harian lewat $merge, merapikan keluaran terminal dengan tabel ASCII, dan menyiapkan jalan membawa data ke chart library eksternal. Sebuah suite tes menutup seluruh proyek.
Dashboard Multi-Metriks dengan $facet #
Stage $facet memecah satu aliran document menjadi beberapa sub-pipeline yang berjalan pada input yang sama — hasilnya dikemas sebagai satu document berisi array per cabang. Ketiga laporan kita masuk natural ke dalam pola ini:
use proyek_analitik
db.transaksi.aggregate([
{ $facet: {
omzetKategori: [
{ $unwind: "$items" },
{ $group: { _id: "$items.kategori", omzet: { $sum: { $multiply: ["$items.harga", "$items.jumlah"] } } } },
{ $sort: { omzet: -1 } },
],
produkTerlaris: [
{ $unwind: "$items" },
{ $group: { _id: "$items.produk", unit: { $sum: "$items.jumlah" } } },
{ $sort: { unit: -1 } },
{ $limit: 3 },
],
perBulan: [
{ $group: {
_id: { $dateTrunc: { date: "$tanggal", unit: "month" } },
transaksi: { $sum: 1 },
omzet: { $sum: "$total" },
} },
{ $sort: { _id: 1 } },
],
} },
])Output yang Dihasilkan:
switched to db proyek_analitik
[
{
omzetKategori: [
{ _id: 'devops', omzet: 490354000 },
{ _id: 'database', omzet: 453185000 },
{ _id: 'pemrograman', omzet: 451730000 },
{ _id: 'bisnis', omzet: 412945000 }
],
produkTerlaris: [
{ _id: 'Kubernetes di Produksi', unit: 1097 },
{ _id: 'Statistika untuk Keputusan', unit: 1059 },
{ _id: 'Desain Skema Document', unit: 1056 }
],
perBulan: [
{
_id: ISODate('2026-01-01T00:00:00.000Z'),
transaksi: 362,
omzet: 333395000
},
{
_id: ISODate('2026-02-01T00:00:00.000Z'),
transaksi: 295,
omzet: 259248000
},
{
_id: ISODate('2026-03-01T00:00:00.000Z'),
transaksi: 331,
omzet: 308835000
},
{
_id: ISODate('2026-04-01T00:00:00.000Z'),
transaksi: 334,
omzet: 296978000
},
{
_id: ISODate('2026-05-01T00:00:00.000Z'),
transaksi: 340,
omzet: 304363000
},
{
_id: ISODate('2026-06-01T00:00:00.000Z'),
transaksi: 338,
omzet: 305395000
}
]
}
]Tiga hal patut dicermati dari hasilnya. Pertama, cabang produkTerlaris sengaja memeringkat berdasarkan unit — dan hasilnya berbeda dengan peringkat omzet Bagian 2: Statistika untuk Keputusan menembus tiga besar unit (1.059) padahal tidak ada di tiga besar omzet. Ukuran yang dipilih mengubah jawabannya. Kedua, seluruh angka identik dengan laporan Bagian 2 — $facet tidak mengubah cara menghitung, hanya cara mengemas. Ketiga, hasil $facet adalah satu document; jika dashboard dibatasi pada satu kota atau satu rentang, letakkan $match sebelum $facet agar ketiga cabang menerima input yang sudah tersaring.
Materialisasi dengan $merge #
Semua laporan sejauh ini dihitung saat dibutuhkan (compute-on-read). Pola ini tepat selama perhitungannya cepat — tetapi laporan harian yang dibuka berulang kali oleh banyak orang lebih hemat dihitung sekali lalu disimpan (compute-on-write). Stage $merge menulis hasil pipeline ke collection tujuan, dan berkat opsi whenMatched/whenNotMatched, penulisan ini aman diulang:
db.transaksi.aggregate([
{ $group: {
_id: { $dateTrunc: { date: "$tanggal", unit: "day" } },
transaksi: { $sum: 1 },
omzet: { $sum: "$total" },
} },
{ $merge: {
into: "laporan_harian",
on: "_id",
whenMatched: "replace",
whenNotMatched: "insert",
} },
])
db.laporan_harian.countDocuments()
db.laporan_harian.find({ _id: ISODate("2026-06-30T00:00:00Z") })Output yang Dihasilkan:
181
[
{
_id: ISODate('2026-06-30T00:00:00.000Z'),
transaksi: 10,
omzet: 8145000
}
]Pipeline di atas meringkas transaksi menjadi satu baris per hari, lalu $merge menulisnya ke laporan_harian dengan _id berupa tanggal terpotong — kunci yang unik per hari. whenMatched: "replace" menimpa baris lama dengan nilai baru, whenNotMatched: "insert" menambah hari yang belum ada; jalankan pipeline ini lima kali pun jumlah baris tetap 181. Nilai baris 30 Juni persis angka omzetHarian() Bagian 2 — dua jalan berbeda menuju angka yang sama, verifikasi silang yang berharga.
Kapan materialisasi layak dipakai? Saat dua kondisi terpenuhi bersamaan: laporan dibuka jauh lebih sering daripada datanya berubah, dan perhitungannya cukup mahal untuk tidak diulang-ulang. Rekap harian dari transaksi historis memenuhi keduanya — transaksi lama tidak berubah, sehingga pembaruan cukup menyasar hari berjalan, misalnya lewat $match hari ini sebelum $group.
Tabel ASCII di Terminal #
Sebelum membawa data ke grafis, terminal sendiri bisa tampil rapi dengan bantuan padEnd() dan padStart() dari JavaScript. Blok berikut membaca laporan_harian — bukan transaksi — lalu merangkumnya per bulan; perhatikan $dateTrunc kini bekerja pada $_id yang memang sudah bertipe Date:
const bulanan = db.laporan_harian.aggregate([
{ $group: { _id: { $dateTrunc: { date: "$_id", unit: "month" } }, omzet: { $sum: "$omzet" } } },
{ $sort: { _id: 1 } },
]).toArray()
const kolom = (b, o, d) => b.padEnd(7) + " | " + o.padStart(12) + " | " + d.padStart(12)
const isi = [kolom("Bulan", "Omzet", "Delta"), "-".repeat(37)]
bulanan.forEach(function (r, i) {
const lalu = i > 0 ? bulanan[i - 1].omzet : null
isi.push(kolom(
r._id.toISOString().slice(0, 7),
String(r.omzet),
lalu === null ? "-" : String(r.omzet - lalu)
))
})
print(isi.join("\n"))Output yang Dihasilkan:
Bulan | Omzet | Delta
-------------------------------------
2026-01 | 333395000 | -
2026-02 | 259248000 | -74147000
2026-03 | 308835000 | 49587000
2026-04 | 296978000 | -11857000
2026-05 | 304363000 | 7385000
2026-06 | 305395000 | 1032000Kolom Delta dihitung di JavaScript dari array bulanan — jalur yang lebih ringkas daripada $setWindowFields ketika datanya sudah kecil dan berada di tangan. Seluruh baris dirangkai menjadi satu string dengan join("\n") lalu dicetak sekali, sehingga keluarannya rapat tanpa baris kosong penyebar REPL. Omzet bulanan dari baris harian (Rp1.808.214.000 total) cocok persis dengan penjumlahan langsung dari transaksi — konsistensi yang diuji lagi oleh suite tes di bawah.
Membawa Data ke Chart Library #
Tabel ASCII menyajikan angka; grafik menjual cerita. Collection laporan_harian sudah berbentuk siap grafik — satu baris per hari dengan dua kolom numerik — sehingga tinggal diekspor. Dari terminal, mongoexport (bagian MongoDB Database Tools) menuliskannya menjadi berkas JSON:
mongoexport --uri "mongodb://127.0.0.1:27017/?directConnection=true" \
--db proyek_analitik --collection laporan_harian \
--type=json --out laporan_harian.jsonBila datanya dikonsumsi aplikasi (API, skrip visualisasi), mongosh sendiri bisa menjadi pengekspor — EJSON.stringify() menghasilkan JSON dengan tanggal dalam bentuk terstruktur:
const tigaHari = db.laporan_harian.find().sort({ _id: -1 }).limit(3).toArray()
print(EJSON.stringify(tigaHari, null, 2))Output yang Dihasilkan:
[
{
"_id": {
"$date": "2026-06-30T00:00:00Z"
},
"transaksi": 10,
"omzet": 8145000
},
{
"_id": {
"$date": "2026-06-29T00:00:00Z"
},
"transaksi": 13,
"omzet": 11563000
},
{
"_id": {
"$date": "2026-06-28T00:00:00Z"
},
"transaksi": 14,
"omzet": 13044000
}
]Field $date pada tiap _id adalah konvensi Extended JSON: nilainya string ISO 8601 yang langsung diterima new Date() maupun parser tanggal chart library (Chart.js, Plotly, ECharts, D3). Dari titik ini tampilan grafis sepenuhnya urusan sisi visualisasi: omzet harian cocok sebagai line chart, omzetKategori sebagai donut chart, dan produkTerlaris sebagai bar chart — ketiganya berasal dari satu pipeline $facet yang sama.
Suite Tes Proyek #
Empat bagian telah dirakit — saatnya memastikan semuanya benar dan tetap benar. Suite berikut bersifat mandiri: dataset dibangun ulang di database proyek_analitik_tes dengan generator deterministik yang sama (disalin dari dataset-seed.js), diuji, lalu dibersihkan:
// tests/semua-tes.js — suite tes mandiri proyek dashboard analitik
// Dataset dibangun ulang di database terpisah proyek_analitik_tes dengan
// generator deterministik yang sama, lalu dibersihkan setelah selesai.
// Jalankan dari folder proyek (urutan bebas terhadap src/):
// mongosh "mongodb://127.0.0.1:27017/?directConnection=true" --file tests/semua-tes.js
var tes = db.getSiblingDB("proyek_analitik_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)
}
// --- generator deterministik, identik dengan src/dataset-seed.js ---
function mulberry32(seed) {
var t = seed >>> 0
return function () {
t += 0x6d2b79f5
var r = Math.imul(t ^ (t >>> 15), 1 | t)
r ^= r + Math.imul(r ^ (r >>> 7), 61 | r)
return ((r ^ (r >>> 14)) >>> 0) / 4294967296
}
}
var acak = mulberry32(20260101)
function pilih(daftar) {
return daftar[Math.floor(acak() * daftar.length)]
}
function rentang(min, maks) {
return min + Math.floor(acak() * (maks - min + 1))
}
var PRODUK = [
{ judul: "Pemrograman Modern dengan Go", kategori: "pemrograman", harga: 148000 },
{ judul: "Rust untuk Sistem Andal", kategori: "pemrograman", harga: 165000 },
{ judul: "TypeScript dalam Praktik", kategori: "pemrograman", harga: 132000 },
{ judul: "MongoDB untuk Pengembang", kategori: "database", harga: 175000 },
{ judul: "Desain Skema Document", kategori: "database", harga: 150000 },
{ judul: "SQL dan Relational Dasar", kategori: "database", harga: 128000 },
{ judul: "Docker dari Dasar", kategori: "devops", harga: 138000 },
{ judul: "Kubernetes di Produksi", kategori: "devops", harga: 182000 },
{ judul: "Observabilitas Aplikasi", kategori: "devops", harga: 156000 },
{ judul: "Analisis Keuangan Praktis", kategori: "bisnis", harga: 121000 },
{ judul: "Manajemen Produk Digital", kategori: "bisnis", harga: 135000 },
{ judul: "Statistika untuk Keputusan", kategori: "bisnis", harga: 144000 },
]
var KOTA = ["Jakarta", "Bandung", "Surabaya", "Yogyakarta", "Medan"]
var CHANNEL = ["toko", "online", "aplikasi"]
var MULAI_MS = Date.UTC(2026, 0, 1)
var SATU_HARI = 24 * 60 * 60 * 1000
function buatTransaksi() {
var offsetHari = rentang(0, 180)
var tanggal = new Date(
MULAI_MS + offsetHari * SATU_HARI +
rentang(8, 20) * 60 * 60 * 1000 + rentang(0, 59) * 60 * 1000
)
var jumlahItem = rentang(1, 3)
var items = []
var dipakai = {}
while (items.length < jumlahItem) {
var p = pilih(PRODUK)
if (dipakai[p.judul]) continue
dipakai[p.judul] = true
items.push({
produk: p.judul, kategori: p.kategori,
harga: p.harga, jumlah: rentang(1, 5),
})
}
var total = 0
items.forEach(function (it) { total += it.harga * it.jumlah })
return {
tanggal: tanggal, kota: pilih(KOTA), channel: pilih(CHANNEL),
items: items, total: total,
}
}
function siapkanDataset() {
tes.dropDatabase()
var batch = []
for (var i = 0; i < 2000; i++) {
batch.push(buatTransaksi())
}
tes.transaksi.insertMany(batch)
}
function tesDataset() {
assertSama(tes.transaksi.countDocuments({}), 2000, "dataset memuat 2000 transaksi")
const rentangTanggal = tes.transaksi.aggregate([
{ $sort: { tanggal: 1 } },
{ $group: { _id: null, awal: { $first: "$tanggal" }, akhir: { $last: "$tanggal" } } },
]).toArray()[0]
assertSama(rentangTanggal.awal, ISODate("2026-01-01T10:25:00.000Z"), "tanggal terawal 1 Januari 2026")
assertSama(rentangTanggal.akhir, ISODate("2026-06-30T19:36:00.000Z"), "tanggal terakhir 30 Juni 2026")
const melanggar = tes.transaksi.find({}).toArray().filter(function (t) {
var jumlah = 0
t.items.forEach(function (it) { jumlah += it.harga * it.jumlah })
return jumlah !== t.total
}).length
assertSama(melanggar, 0, "field total = penjumlahan harga x jumlah semua item")
}
function tesOmzet() {
const total = tes.transaksi.aggregate([
{ $group: { _id: null, omzet: { $sum: "$total" } } },
]).toArray()[0].omzet
const ulang = tes.transaksi.find({}).toArray()
.reduce(function (a, t) { return a + t.total }, 0)
assertSama(total, ulang, "omzet aggregation = penjumlahan ulang di sisi klien")
assertSama(total, 1808214000, "omzet total sesuai angka kanonik dataset")
const manual = {}
tes.transaksi.find({}, { items: 1 }).forEach(function (t) {
t.items.forEach(function (it) {
manual[it.kategori] = (manual[it.kategori] || 0) + it.harga * it.jumlah
})
})
const perKategori = tes.transaksi.aggregate([
{ $unwind: "$items" },
{ $group: {
_id: "$items.kategori",
omzet: { $sum: { $multiply: ["$items.harga", "$items.jumlah"] } },
} },
{ $sort: { _id: 1 } },
]).toArray().map(function (r) { return [r._id, r.omzet] })
const ulangKategori = Object.keys(manual).sort()
.map(function (k) { return [k, manual[k]] })
assertSama(perKategori, ulangKategori, "omzet per kategori pipeline = hitung ulang manual")
assertSama(
perKategori.reduce(function (a, r) { return a + r[1] }, 0),
total,
"omzet per kategori berjumlah total omzet"
)
const perBulan = tes.transaksi.aggregate([
{ $group: {
_id: { $dateTrunc: { date: "$tanggal", unit: "month" } },
omzet: { $sum: "$total" },
} },
{ $sort: { _id: 1 } },
]).toArray().map(function (r) { return r._id.toISOString().slice(0, 7) })
assertSama(
perBulan,
["2026-01", "2026-02", "2026-03", "2026-04", "2026-05", "2026-06"],
"enam bulan terurut dari Januari sampai Juni"
)
}
function tesProduk() {
const produkUnik = tes.transaksi.aggregate([
{ $unwind: "$items" },
{ $group: { _id: "$items.produk" } },
{ $count: "n" },
]).toArray()[0].n
assertSama(produkUnik, 12, "12 judul produk unik ikut terjual")
const terlaris = tes.transaksi.aggregate([
{ $unwind: "$items" },
{ $group: {
_id: "$items.produk",
omzet: { $sum: { $multiply: ["$items.harga", "$items.jumlah"] } },
} },
{ $sort: { omzet: -1 } },
{ $limit: 3 },
]).toArray().map(function (r) { return [r._id, r.omzet] })
assertSama(terlaris, [
["Kubernetes di Produksi", 199654000],
["MongoDB untuk Pengembang", 170625000],
["Rust untuk Sistem Andal", 164670000],
], "tiga produk terlaris beserta omzetnya")
const total = 1808214000
const kontribusi = terlaris.map(function (r) {
return Math.round((r[1] / total) * 1000) / 10
})
assertSama(kontribusi, [11, 9.4, 9.1], "kontribusi top-3 terhadap omzet total")
}
function tesTren() {
const delta = tes.transaksi.aggregate([
{ $group: {
_id: { $dateTrunc: { date: "$tanggal", unit: "month" } },
omzet: { $sum: "$total" },
} },
{ $sort: { _id: 1 } },
{ $setWindowFields: {
sortBy: { _id: 1 },
output: {
omzetLalu: { $shift: { output: "$omzet", by: -1, default: null } },
},
} },
{ $set: { delta: { $subtract: ["$omzet", "$omzetLalu"] } } },
]).toArray().map(function (r) { return r.delta })
const bulanan = [333395000, 259248000, 308835000, 296978000, 304363000, 305395000]
const deltaManual = bulanan.map(function (v, i) {
return i === 0 ? null : v - bulanan[i - 1]
})
assertSama(delta, deltaManual, "delta $setWindowFields = selisih manual antarbulan")
assertSama(delta[0], null, "bulan pertama tidak memiliki pembanding")
}
function tesIndex() {
tes.transaksi.createIndex({ tanggal: 1 })
tes.transaksi.createIndex({ "items.kategori": 1, tanggal: 1 })
assertSama(
tes.transaksi.getIndexes().map(function (i) { return i.name }).sort(),
["_id_", "items.kategori_1_tanggal_1", "tanggal_1"],
"index tunggal dan compound terpasang"
)
const rencana = tes.transaksi.find({
tanggal: {
$gte: ISODate("2026-03-01T00:00:00Z"),
$lt: ISODate("2026-04-01T00:00:00Z"),
},
}).explain()
assertSama(rencana.queryPlanner.winningPlan.inputStage.stage, "IXSCAN",
"kueri rentang tanggal memakai IXSCAN")
}
function tesMaterialisasi() {
const pipeline = [
{ $group: {
_id: { $dateTrunc: { date: "$tanggal", unit: "day" } },
omzet: { $sum: "$total" },
transaksi: { $sum: 1 },
} },
{ $merge: { into: "laporan_harian", on: "_id", whenMatched: "replace", whenNotMatched: "insert" } },
]
tes.transaksi.aggregate(pipeline)
const hariUnik = {}
tes.transaksi.find({}, { tanggal: 1 }).forEach(function (t) {
hariUnik[t.tanggal.toISOString().slice(0, 10)] = true
})
assertSama(
tes.laporan_harian.countDocuments({}),
Object.keys(hariUnik).length,
"laporan_harian berisi satu baris per hari unik"
)
tes.transaksi.aggregate(pipeline)
assertSama(
tes.laporan_harian.countDocuments({}),
Object.keys(hariUnik).length,
"materialisasi ulang tidak menduplikasi baris"
)
const akhirJuni = tes.laporan_harian.findOne({ _id: ISODate("2026-06-30T00:00:00.000Z") })
assertSama([akhirJuni.transaksi, akhirJuni.omzet], [10, 8145000],
"baris 30 Juni sesuai laporan harian")
}
siapkanDataset()
tesDataset()
tesOmzet()
tesProduk()
tesTren()
tesIndex()
tesMaterialisasi()
print("SEMUA TES LULUS")
tes.dropDatabase()Jalankan dari folder proyek:
mongosh "mongodb://127.0.0.1:27017/?directConnection=true" --file tests/semua-tes.jsOutput yang Dihasilkan:
lulus - dataset memuat 2000 transaksi
lulus - tanggal terawal 1 Januari 2026
lulus - tanggal terakhir 30 Juni 2026
lulus - field total = penjumlahan harga x jumlah semua item
lulus - omzet aggregation = penjumlahan ulang di sisi klien
lulus - omzet total sesuai angka kanonik dataset
lulus - omzet per kategori pipeline = hitung ulang manual
lulus - omzet per kategori berjumlah total omzet
lulus - enam bulan terurut dari Januari sampai Juni
lulus - 12 judul produk unik ikut terjual
lulus - tiga produk terlaris beserta omzetnya
lulus - kontribusi top-3 terhadap omzet total
lulus - delta $setWindowFields = selisih manual antarbulan
lulus - bulan pertama tidak memiliki pembanding
lulus - index tunggal dan compound terpasang
lulus - kueri rentang tanggal memakai IXSCAN
lulus - laporan_harian berisi satu baris per hari unik
lulus - materialisasi ulang tidak menduplikasi baris
lulus - baris 30 Juni sesuai laporan harian
SEMUA TES LULUSSuite ini memeriksa tiga lapis kebenaran. Lapis data: 2000 transaksi, rentang tanggal tepat, dan invarian total = penjumlahan isi items pada setiap document. Lapis konsistensi: setiap angka pipeline dihitung ulang dengan cara sederhana di sisi klien — omzet total, omzet per kategori, delta antarbulan — plus angka kanonik (misalnya 1808214000) yang akan berubah bila generator seed tidak sengaja dimodifikasi. Lapis perilaku: index terpasang dan dipakai (IXSCAN), serta $merge idempoten — 19 assertion, satu Error cukup untuk menghentikan semuanya.
Apa Berikutnya #
Proyek dashboard analitik tuntas: dataset deterministik 2000 transaksi, tiga laporan aggregation yang dibedah stage per stage, dua index dengan bukti explain sebelum-sesudah, dashboard $facet, laporan harian termaterialisasi, dan suite tes yang lulus penuh. Seluruh pola yang dipakai — $dateTrunc, $setWindowFields, $facet, $merge — adalah bahan yang sama untuk dashboard produksi berskala jutaan transaksi. Berikutnya, Proyek Pencarian Produk mengangkat sisi lain dari data: menemukan, bukan meringkas — text index berbobot, filter gabungan, dan pagination hasil pencarian.