Data bersih yang tersimpan di data/staging/ masih berbentuk berkas datar; dashboard butuh tabel dengan tipe yang benar dan grain yang jelas. Pada bagian ini kita merancang model analitik gaya dbt pada folder models/: tabel sumber dimuat apa adanya, model staging mengkoersi dan menurunkan ulang kolom, lalu fact table menyimpan grain terhalus yang dibutuhkan seluruh metrik. Struktur berlapis ini persis pola staging-marts yang dibahas pada chapter Model Staging, Intermediate, dan Marts dan dbt; panduan resmi penulisan model SQL dbt ada di https://docs.getdbt.com/docs/build/sql-models . Bedanya dengan proyek data warehouse sebelumnya: kita tidak membangun star schema lengkap, melainkan mart analitik yang lebih ramping — pilihannya kita bahas di akhir bagian dan merujuk chapter Data Marts.

Peta Model Berlapis #

Alur model dari sumber sampai marts digambarkan sebagai berikut; tiga lapisan pertama dibangun pada bagian ini, marts menyusul pada Bagian 6:

Setiap lapisan punya satu tanggung jawab:

LapisanTabelTanggung jawab
sumbersumber_pesanan, sumber_eventsmemuat hasil pembersihan, seluruh kolom VARCHAR
stagingstg_pesanan, stg_eventskoersi tipe dan penurunan kolom baru
factfct_pesanangrain satu baris item pesanan + penanda status
martsmkt_*agregat siap dashboard (Bagian 6)

Model Staging Pesanan #

models/stg_pesanan.sql mengkoersi seluruh kolom dan menurunkan ulang total dari komponen:

sql
-- Model staging pesanan: koersi tipe dan penurunan ulang total.
-- Sumber: tabel sumber_pesanan (hasil pembersihan, seluruh kolom VARCHAR).
-- Total diturunkan dari komponen (kuantitas * harga_satuan - diskon), bukan
-- disalin dari kolom total, sehingga satu sumber kebenaran untuk nilai jual.
WITH bertipe AS (
    SELECT
        pesanan_id,
        CAST(tanggal AS DATE) AS tanggal,
        pelanggan_id,
        kanal,
        produk_id,
        nama_produk,
        kategori,
        CAST(kuantitas AS INTEGER) AS kuantitas,
        CAST(harga_satuan AS BIGINT) AS harga_satuan,
        CAST(diskon AS BIGINT) AS diskon,
        CAST(total AS BIGINT) AS total_sumber,
        status
    FROM sumber_pesanan
)
SELECT
    pesanan_id,
    tanggal,
    pelanggan_id,
    kanal,
    produk_id,
    nama_produk,
    kategori,
    kuantitas,
    harga_satuan,
    diskon,
    kuantitas * harga_satuan - diskon AS total,
    total_sumber,
    status
FROM bertipe

Mari kita telaah bagian-bagian model di atas:

  1. CTE bertipe: seluruh CAST terkumpul di satu tempat; selektor luar tinggal memakai kolom yang sudah bertipe, sehingga ekspresi kuantitas * harga_satuan - diskon tidak perlu mengulang CAST.
  2. Kolom total_sumber: total asli dari hasil pembersihan tetap disimpan di samping total hasil hitung; selisih keduanya sudah nol karena Bagian 3 memperbaiki total, tetapi kolom ini menjaga jejak audit.
  3. kuantitas * harga_satuan - diskon AS total: nilai jual diturunkan dari komponen di dalam SQL, prinsip yang sama dengan kolom kalkulasi dbt.

Model Fact #

models/fct_pesanan.sql menetapkan grain dan menambahkan penanda yang dipakai seluruh marts:

sql
-- Model fact: satu baris per item pesanan (pesanan_id, produk_id).
-- Atribut produk sengaja dibiarkan terdenormalisasi (nama_produk, kategori)
-- karena model ini adalah mart analitik untuk dashboard, bukan star schema
-- gudang data; lihat penjelasan pada Bagian 4 halaman proyek.
SELECT
    pesanan_id,
    tanggal,
    strftime(tanggal, '%Y-%m') AS bulan,
    pelanggan_id,
    kanal,
    produk_id,
    nama_produk,
    kategori,
    kuantitas,
    harga_satuan,
    diskon,
    total,
    status,
    status = 'selesai' AS is_selesai
FROM stg_pesanan

Tiga keputusan penting pada model tersebut:

  1. Grain satu baris item pesanan: kunci alami adalah pasangan pesanan_id + produk_id; pesanan dua produk menempati dua baris, sama seperti fact_pesanan pada proyek gudang data.
  2. strftime(tanggal, '%Y-%m') AS bulan: kolom bulan diturunkan sekali di sini agar marts tidak mengulang logika format tanggal.
  3. status = 'selesai' AS is_selesai: penanda boolean ini mengunci definisi GMV dari Bagian 1 — setiap mart yang memfilter is_selesai otomatis konsisten dengan filter_status pada metric_definitions.yaml.

Eksekusi Nyata Model #

Model bukan sekadar berkas SQL; src/models_runner.py menjalankan ketiganya melalui DuckDB dengan pola CREATE OR REPLACE TABLE <nama_model> AS <select>. Skrip berikut mengeksekusi seluruh model pada database di memori, lalu memeriksa struktur hasilnya:

python
from pathlib import Path

import duckdb

from src import models_runner

con = duckdb.connect(":memory:")
jumlah = models_runner.jalankan_semua(
    con,
    Path("models"),
    Path("data/staging/pesanan_bersih.csv"),
    Path("data/staging/events_bersih.jsonl"),
)
print("eksekusi model (database di memori):")
for nama, baris in jumlah.items():
    print(f"  {nama:16s} {baris:4d} baris")

print("struktur fct_pesanan:")
kolom = con.execute("DESCRIBE fct_pesanan").fetchall()
for nama, tipe in [(k[0], k[1]) for k in kolom]:
    print(f"  {nama:14s} {tipe}")

Output yang Dihasilkan:

text
eksekusi model (database di memori):
  sumber_pesanan     33 baris
  sumber_events     112 baris
  stg_pesanan        33 baris
  stg_events        112 baris
  fct_pesanan        33 baris
struktur fct_pesanan:
  pesanan_id     VARCHAR
  tanggal        DATE
  bulan          VARCHAR
  pelanggan_id   VARCHAR
  kanal          VARCHAR
  produk_id      VARCHAR
  nama_produk    VARCHAR
  kategori       VARCHAR
  kuantitas      INTEGER
  harga_satuan   BIGINT
  diskon         BIGINT
  total          BIGINT
  status         VARCHAR
  is_selesai     BOOLEAN

Baris tidak bertambah maupun berkurang dari staging ke fact — 33 masuk, 33 keluar — karena model ini murni transformasi kolom, bukan agregasi. Tipe kolom hasil DESCRIBE membuktikan koersi bekerja: tanggal menjadi DATE, total menjadi BIGINT, dan is_selesai menjadi BOOLEAN.

Mart Ramping, Bukan Star Schema #

Keputusan desain terakhir: mengapa fct_pesanan menyimpan nama_produk dan kategori secara terdenormalisasi, padahal proyek gudang data memisahkannya ke dim_produk? Karena tujuan model ini berbeda. Gudang data melayani analisis lintas domain dalam jangka panjang sehingga normalisasi ke dimensi terpisah terbayar; mart analitik melayani satu dashboard dengan angka tetap, sehingga denormalisasi membuat query mart lebih pendek dan pipeline lebih sederhana. Ketika kebutuhan bertambah — misalnya riwayat perubahan harga produk — saat itulah atribut naik ke tabel dimensi tersendiri, sebagaimana dibahas pada chapter Data Marts.

Tiga model inti sudah berdiri dan terbukti tereksekusi. Pada Bagian 5 kita merangkai seluruh tahap menjadi satu pipeline yang bisa dijalankan dengan satu perintah.