Pusat bintang kita isi sekarang. Fact table berbeda dari tabel dimensi pada dua hal: isinya jauh lebih panjang tumbuhnya, dan setiap barisnya harus menghubungkan ke dimensi yang tepat pada saat transaksi terjadi, bukan dimensi versi terbaru. Pada bagian ini kita membangun src/fact_loader.py yang menyelesaikan resolusi foreign key, menyimpan degenerate dimension, dan menjaga pemuatan tetap idempoten. Konsep grain dan jenis measure telah kita kunci di Bagian 1 dan 2; teorinya dibahas chapter Fact Table dan Dimension Table.

Struktur fact_pesanan #

DDL pada src/db.py mendefinisikan sebelas kolom sesuai spesifikasi Bagian 2:

python
    "fact_pesanan": """
        CREATE TABLE IF NOT EXISTS fact_pesanan (
            pesanan_id   VARCHAR,
            baris        INTEGER,
            tanggal_sk   INTEGER,
            pelanggan_sk INTEGER,
            produk_sk    INTEGER,
            toko_sk      INTEGER,
            jumlah       INTEGER,
            harga_satuan BIGINT,
            diskon       BIGINT,
            total        BIGINT,
            status       VARCHAR
        )
    """,

Empat kolom *_sk adalah foreign key ke dimensi, pesanan_id dan baris adalah degenerate dimension sekaligus pembeda grain, dan empat kolom terakhir adalah measure beserta statusnya. Sama seperti dimensi, tidak ada constraint; tabel ini adalah transaction fact table murni: satu baris peristiwa, tidak pernah diubah setelah termuat.

Resolusi Foreign Key #

Seluruh pekerjaan berat fact loader terjadi pada satu pernyataan SQL di src/fact_loader.py:

python
def muat_fact_pesanan(con, df_pesanan) -> int:
    """Muat baris pesanan baru ke fact_pesanan; baris yang sudah ada dilewati.

    Resolusi pelanggan_sk memakai join SCD Type 2: versi dimensi yang dipilih
    adalah versi yang sah pada tanggal transaksi (valid_from <= tanggal <=
    valid_to), bukan sekadar versi terbaru. pesanan_id dan baris disimpan
    sebagai degenerate dimension. Mengembalikan jumlah baris yang disisipkan.
    """
    df = df_pesanan.copy()
    df["tanggal"] = pd.to_datetime(df["tanggal"]).dt.date
    for kolom in ("jumlah", "harga_satuan", "diskon"):
        df[kolom] = df[kolom].astype("int64")
    con.register("sumber_fact", df)
    hasil = con.execute(
        """
        INSERT INTO fact_pesanan
        SELECT s.pesanan_id,
               s.baris,
               t.tanggal_sk,
               p.pelanggan_sk,
               r.produk_sk,
               k.toko_sk,
               s.jumlah::INTEGER,
               s.harga_satuan::BIGINT,
               s.diskon::BIGINT,
               (s.jumlah * s.harga_satuan - s.diskon)::BIGINT AS total,
               s.status
        FROM sumber_fact AS s
        JOIN dim_tanggal AS t
          ON t.tanggal = s.tanggal
        JOIN dim_pelanggan AS p
          ON p.pelanggan_id = s.pelanggan_id
         AND s.tanggal BETWEEN p.valid_from AND p.valid_to
        JOIN dim_produk AS r
          ON r.produk_id = s.produk_id
        JOIN dim_toko AS k
          ON k.toko_id = s.toko_id
        WHERE NOT EXISTS (
            SELECT 1 FROM fact_pesanan AS f
            WHERE f.pesanan_id = s.pesanan_id AND f.baris = s.baris
        )
        """
    ).fetchall()
    con.unregister("sumber_fact")
    return int(hasil[0][0]) if hasil else 0

Mari kita telaah bagian-bagian kuncinya:

  1. Baris df["tanggal"] = pd.to_datetime(...).dt.date: kolom tanggal dinormalisasi menjadi objek date agar join dengan dim_tanggal.tanggal tidak gagal karena perbedaan tipe antara teks CSV dan DATE.
  2. Baris for kolom in (...): kolom numerik dilemparkan ke int64 sehingga DuckDB membacanya sebagai BIGINT dan koma desimal terselip tidak lolos.
  3. Baris JOIN dim_tanggal ... ON t.tanggal = s.tanggal: tanggal teks sumber dipetakan ke smart key tanggal_sk.
  4. Baris JOIN dim_pelanggan ... AND s.tanggal BETWEEN p.valid_from AND p.valid_to: inilah resolusi sadar tanggal; saat Bagian 6 menambahkan versi kedua seorang pelanggan, pesanan lama tetap menunjuk versi yang sah pada saat transaksi, bukan versi terbaru.
  5. Baris (s.jumlah * s.harga_satuan - s.diskon)::BIGINT AS total: measure total dihitung sekali saat pemuatan; query analitik cukup SUM(total) tanpa menghitung ulang.
  6. Baris WHERE NOT EXISTS ...: pasangan pesanan_id dan baris yang sudah ada dilewati, sehingga menjalankan ulang pemuatan tidak pernah menggandakan fakta.

Menjalankan Pemuatan Fact #

Dengan dimensi Bagian 3 sudah termuat, skrip demo_fact.py memuat data/pesanan.csv lalu memuat ulang berkas yang sama:

python
df_pesanan = pd.read_csv("data/pesanan.csv")
disisipkan = fact_loader.muat_fact_pesanan(con, df_pesanan)
print(f"pemuatan pertama : {disisipkan} baris disisipkan")
disisipkan = fact_loader.muat_fact_pesanan(con, df_pesanan)
print(f"pemuatan kedua   : {disisipkan} baris disisipkan")

Output yang Dihasilkan:

text
pemuatan pertama : 32 baris disisipkan
pemuatan kedua   : 0 baris disisipkan

=== grain: baris per pesanan ===
  ORD-0001 2 baris; nilai Rp254.000
  ORD-0002 2 baris; nilai Rp149.000
  ORD-0003 1 baris; nilai Rp70.000
  ORD-0004 2 baris; nilai Rp230.000
  ORD-0005 1 baris; nilai Rp285.000

=== degenerate dimension + resolusi SK (ORD-0004) ===
  ORD-0004/1 tgl_sk=20250214 plg_sk=3 prk_sk=1 tko_sk=2 | Bandung | Kopi Arabika Gayo 250g @ TokoKita Dago x1 = Rp85.000
  ORD-0004/2 tgl_sk=20250214 plg_sk=3 prk_sk=7 tko_sk=2 | Bandung | French Press 350ml @ TokoKita Dago x1 = Rp145.000

Pemuatan kedua menyisipkan nol baris: idempotensi bekerja. Pesanan ORD-0004 menempati dua baris sesuai grain, dan kedua barisnya berhasil digabungkan kembali ke atribut Bandung dan TokoKita Dago melalui surrogate key. Perilaku ini dikunci tests/test_fact.py yang menguji resolusi kunci, hitungan total, idempotensi, dan baris di luar kalender. Kini dimensi dan fact table termuat terpisah; Bagian 5 merangkai semuanya menjadi satu pipeline terjadwal dengan model SQL gaya dbt.