Atribut pelanggan berubah: orang pindah kota, segmen naik dari reguler ke loyal. Bila dim_pelanggan hanya menyimpan kondisi terkini, pesanan lama Dewi di Bandung akan tampil seolah terjadi di Jakarta begitu dia pindah, dan analisis per kota menjadi salah secara diam-diam. Slowly Changing Dimension Type 2 menjawab masalah ini dengan menyimpan setiap versi atribut sebagai baris tersendiri, sesuai teori chapter Slowly Changing Dimensions. Pada bagian ini kita menjalankan src/scd2.py pada data perubahan sungguhan lalu membuktikan fact table memilih versi yang tepat.

Tiga Strategi Menyikapi Perubahan #

StrategiPerlakuanAkibat pada analisis
Type 1: timpanilai lama ditimpa nilai baruriwayat hilang; pesanan lama ikut berpindah kota
Type 2: versi barubaris lama ditutup, baris baru dibukariwayat utuh; fact table menunjuk versi saat transaksi
Type 3: kolom tambahankolom kota_lama disimpanhanya satu tingkat riwayat yang mungkin

TokoKita memilih Type 2 untuk dim_pelanggan karena pertanyaan bisnis keenam pada Bagian 1 menanyakan perilaku belanja sebelum dan sesudah pelanggan pindah. Dimensi lain diperlakukan Type 1 sederhana: dim_produk dan dim_toko dimuat ulang penuh oleh model Bagian 5 karena atributnya tidak dipakai membedakan riwayat. Data perubahan sendiri datang dari data/pelanggan_update.csv:

text
pelanggan_id,tanggal_efektif,kota_baru,segmen_baru
PLG-003,2025-03-01,Jakarta,loyal
PLG-005,2025-04-01,,vip

Dewi (PLG-003) pindah dari Bandung ke Jakarta per 1 Maret; Rina (PLG-005) naik segmen dari loyal ke vip per 1 April tanpa berpindah kota, ditandai kolom kota_baru yang kosong.

Inti src/scd2.py adalah fungsi terapkan_perubahan; berikut inti keputusannya per baris perubahan:

python
        versi_kini = con.execute(
            """
            SELECT pelanggan_sk, nama, email, kota, segmen, attr_hash
            FROM dim_pelanggan
            WHERE pelanggan_id = ? AND is_current
            """,
            [baris.pelanggan_id],
        ).fetchone()
        if versi_kini is None:
            hasil["dilewati"].append(f"{baris.pelanggan_id}: pelanggan tidak dikenal")
            continue
        kota_baru = baris.kota_baru if pd.notna(baris.kota_baru) else versi_kini[3]
        segmen_baru = baris.segmen_baru if pd.notna(baris.segmen_baru) else versi_kini[4]
        hash_baru = hash_atribut([versi_kini[1], versi_kini[2], kota_baru, segmen_baru])
        if hash_baru == versi_kini[5]:
            hasil["dilewati"].append(f"{baris.pelanggan_id}: atribut tidak berubah")
            continue

Mari kita telaah logika tersebut:

  1. Baris SELECT ... WHERE is_current: versi yang sedang berlaku dicari berdasarkan natural key; pelanggan yang tidak dikenal dilewati dengan catatan, bukan gagal total.
  2. Baris kota_baru = ... if pd.notna(...) else versi_kini[3]: kolom perubahan yang kosong diisi nilai versi sekarang, sehingga baris Rina yang hanya mengubah segmen tetap mempertahankan kotanya.
  3. Baris hash_baru = hash_atribut(...): sidik jari atribut gabungan dihitung ulang; perbandingan dengan attr_hash versi lama mendeteksi perubahan tanpa membandingkan kolom satu per satu.
  4. Baris if hash_baru == versi_kini[5]: perubahan yang ternyata tidak mengubah atribut apa pun dilewati, dan pemeriksaan lanjutan pada berkas sumber juga melewati perubahan yang sudah pernah diterapkan sehingga fungsi ini idempoten.

Bila lolos seluruh pemeriksaan, dua aksi mengikuti: _tutup_versi mengubah versi lama menjadi valid_to = tanggal_efektif - 1 hari dengan is_current = FALSE, lalu _buka_versi menyisipkan baris baru dengan surrogate key segar, valid_from = tanggal_efektif, valid_to = 9999-12-31, dan is_current = TRUE.

Demo: Pelanggan Pindah Kota #

Pipeline Bagian 5 sudah menerapkan kedua perubahan. Skrip demo_scd2.py memeriksa hasilnya langsung pada gudang data; berikut bagian pertamanya:

python
con = db.connect()
print("=== versi dim_pelanggan untuk PLG-003 dan PLG-005 ===")
for baris in con.execute("""
    SELECT pelanggan_id, kota, segmen, valid_from, valid_to, is_current
    FROM dim_pelanggan
    WHERE pelanggan_id IN ('PLG-003', 'PLG-005')
    ORDER BY pelanggan_id, valid_from
""").fetchall():
    print(f"  {baris[0]} {baris[1]:8s} {baris[2]:8s} "
          f"{baris[3].strftime('%Y-%m-%d')} s.d. {baris[4].strftime('%Y-%m-%d')}  is_current={baris[5]}")

Output yang Dihasilkan (keluaran penuh skrip, dua bagian berikutnya menanyakan pesanan Dewi dan GMV per kota):

text
=== versi dim_pelanggan untuk PLG-003 dan PLG-005 ===
  PLG-003 Bandung  loyal    2025-01-01 s.d. 2025-02-28  is_current=False
  PLG-003 Jakarta  loyal    2025-03-01 s.d. 9999-12-31  is_current=True
  PLG-005 Jakarta  loyal    2025-01-01 s.d. 2025-03-31  is_current=False
  PLG-005 Jakarta  vip      2025-04-01 s.d. 9999-12-31  is_current=True

=== pesanan Dewi (PLG-003) dengan versi kota saat transaksi ===
  ORD-0004 2025-02-14 kota pelanggan saat transaksi: Bandung
  ORD-0007 2025-03-08 kota pelanggan saat transaksi: Jakarta
  ORD-0012 2025-04-19 kota pelanggan saat transaksi: Jakarta

=== GMV per kota pelanggan (status selesai) ===
  Jakarta    Rp1.413.000
  Bandung    Rp505.000
  Surabaya   Rp367.000
  Yogyakarta Rp365.000
  Medan      Rp170.000
  Semarang   Rp70.000

Tiga pesanan Dewi terbagi dengan benar: pesanan 14 Februari menempel pada versi Bandung, dua pesanan setelah 1 Maret menempel pada versi Jakarta. Inilah hasil join BETWEEN pada model fact Bagian 5. Rina mengalami hal yang sama pada segmen: ORD-0008 tercatat sebagai pelanggan loyal, ORD-0010 sebagai vip. Seluruh perilaku ini diuji pada tests/test_scd2.py, termasuk penerapan ganda yang harus menghasilkan nol perubahan baru.

Alternatif ASOF JOIN #

DuckDB menyediakan ASOF JOIN untuk pola “versi terakhir yang mulai berlaku sebelum tanggal tertentu” tanpa menulis BETWEEN. Skrip demo_asof.py menjalankan bentuk berikut:

python
for baris in con.execute("""
    SELECT s.pesanan_id, p.kota
    FROM (SELECT DISTINCT pesanan_id, tanggal_sk FROM fact_pesanan) AS s
    JOIN dim_tanggal AS t ON t.tanggal_sk = s.tanggal_sk
    ASOF JOIN dim_pelanggan AS p
      ON p.pelanggan_id = 'PLG-003'
     AND t.tanggal >= p.valid_from
    WHERE s.pesanan_id IN ('ORD-0004', 'ORD-0007', 'ORD-0012')
    ORDER BY s.pesanan_id
""").fetchall():
    print(f"  {baris[0]} -> kota {baris[1]}")

Output yang Dihasilkan:

text
=== resolusi versi SCD2 dengan ASOF JOIN ===
  ORD-0004 -> kota Bandung
  ORD-0007 -> kota Jakarta
  ORD-0012 -> kota Jakarta

Hasilnya sama dengan BETWEEN karena valid_to versi terbuka kita adalah 9999-12-31; ASOF JOIN lebih ringkas saat dimensi berisi banyak versi berurutan. Fitur ini terdokumentasi pada https://duckdb.org/docs/ . Riwayat pelanggan kini utuh dan teruji; satu kelemahan masih tersisa: bagaimana kita tahu gudang data ini sehat setiap kali pipeline berjalan? Bagian 7 menjawabnya dengan pemeriksaan kualitas data.