Tahap extract menyerahkan data apa adanya, sehingga sekarang meja kita penuh: ada nama pelanggan yang berlumur spasi, harga 1.250.000 yang bukan angka bagi Python, status SELESAI yang bercampur huruf besar, dua baris tanpa kolom wajib, dan satu pesanan yang dikoreksi lewat baris duplikat. Tugas transform adalah mengubah tumpukan ini menjadi baris fact yang siap dimuat, tanpa kehilangan jejak baris yang ditolak. Kita memakai pandas untuk pekerjaan baris per baris karena aturannya banyak dan bercabang; agregasi per kota diserahkan ke SQL pada tahap load.

Aturan Pembersihan #

Lima aturan berikut dijalankan untuk setiap baris, urutannya menentukan hasil akhirnya:

AturanContoh pelanggarTindakan
Kolom teks wajib terisicustomer_name atau city kosongMasuk karantina
quantity bilangan bulat positif-1, x, kosongMasuk karantina
unit_price angka positifabc, 0Masuk karantina
order_time terbaca sebagai timestampbukan-waktuMasuk karantina
order_id duplikat dalam partisikoreksi pesanan yang samaKemunculan terakhir menang

Setiap penolakan dicatat sebagai satu baris karantina berisi nomor baris CSV, order_id, alasan, dan nilai mentahnya. Nomor baris dihitung termasuk baris header, sehingga baris 8 pada berkas merujuk baris data ketujuh.

parse_harga untuk Format Indonesia #

Harga pada berkas sumber memakai dua gaya: 899000 dan 1.250.000. Fungsi kecil berikut menormalkan keduanya:

python src/transform.py — parser harga
def parse_harga(teks: str) -> float:
    """Ubah teks harga Indonesia menjadi float."""
    bersih = teks.replace("Rp", "").replace(" ", "").strip()
    if "." in bersih and "," not in bersih:
        bersih = bersih.replace(".", "")
    try:
        return float(bersih)
    except ValueError as e:
        raise TransformError(f"harga tidak valid: {teks!r}") from e

Kondisi "." in bersih and "," not in bersih perlu dijelaskan: titik tanpa koma dianggap pemisah ribuan (1.250.000 menjadi 1250000), bukan desimal. Bila suatu hari sumber memakai desimal koma seperti 1.250,50, kondisi ini tidak menghapus titiknya; float lalu menolak string tersebut sehingga baris masuk karantina, bukan salah dibaca sebagai 1,25.

clean_orders Baris demi Baris #

Inti transformasi berjalan dalam satu perulangan yang membaca baris mentah dan memutuskan: masuk fact, atau masuk karantina:

python src/transform.py — inti clean_orders (dipangkas)
bersih: dict[str, dict] = {}
karantina: list[dict] = []

for posisi, (_, mentah) in enumerate(df.iterrows()):
    baris_csv = posisi + 2          # baris 1 pada berkas adalah header
    baris = {k: str(mentah.get(k, "")).strip() for k in df.columns}
    order_id = baris.get("order_id", "")
    ditolak = False

    for kolom in KOLOM_WAJIB_TEKS:                  # (order_id, name, city, category)
        if not baris.get(kolom):
            karantina.append({
                "baris": baris_csv, "order_id": order_id,
                "alasan": f"{kolom} kosong", "nilai": "",
            })
            ditolak = True

    try:
        kuantitas = int(baris.get("quantity", ""))
    except ValueError:
        kuantitas = 0
    if kuantitas <= 0 and not ditolak:
        karantina.append({"baris": baris_csv, "order_id": order_id,
                          "alasan": "quantity tidak valid",
                          "nilai": baris.get("quantity", "")})
        ditolak = True

    if ditolak:
        continue

    bersih[order_id] = {
        "order_id": order_id,
        "order_date": waktu.date(),
        "quantity": kuantitas,
        "unit_price": round(harga_satuan, 2),
        "total_price": round(kuantitas * harga_satuan, 2),
        "status": baris.get("status", "").lower(),
    }

Mari kita telaah alurnya:

  1. Baris 4 (baris_csv = posisi + 2): iterrows() memberi indeks baris data mulai nol; +2 mengubahnya menjadi nomor baris berkas yang pembaca bisa langsung cari di editor.
  2. Baris 5: seluruh kolom dipangkas spasinya sekali di awal. Setelah baris ini, " Budi Santoso " menjadi "Budi Santoso".
  3. Baris 9-15: empat kolom teks wajib diperiksa kosong. Satu baris bisa menghasilkan lebih dari satu catatan karantina bila dua kolomnya kosong sekaligus.
  4. Baris 17-27: quantity dicoba sebagai int; kegagalan atau nilai tidak positif memasukkan baris ke karantina. Pemeriksaan unit_price dan order_time mengikuti pola yang sama (dipangkas dari potongan agar tetap mudah dibaca).
  5. Baris 29-30 (continue): baris yang ditolak tidak pernah menyentuh dictionary bersih.
  6. Baris 32-40: baris valid disusun menjadi kamus fact. total_price dihitung di sini sebagai turunan, dan status dinormalkan ke huruf kecil.
  7. Penimpaan bersih[order_id]: karena dictionary berkunci order_id, kemunculan kedua dari pesanan yang sama menimpa kemunculan pertamanya. Efeknya: koreksi yang ditulis ulang menang.

Hasil Pembersihan Nyata #

Partisi 2026-11-03 berisi sepuluh baris mentah. Setelah clean_orders, angkanya menjadi:

Output yang Dihasilkan:

text
baris mentah      : 10
fact bersih       : 7
baris karantina   : 2
duplikat dibuang  : 1

== contoh baris fact hasil pembersihan ==
          order_id  customer_name       city  quantity  unit_price  total_price   status
ORD-2026-11-03-001   Budi Santoso    Jakarta         1    899000.0     899000.0  selesai
ORD-2026-11-03-002 Siti Rahmawati    Bandung         1   1250000.0    1250000.0  selesai
ORD-2026-11-03-003    Andi Wijaya Yogyakarta         2     25000.0      50000.0 diproses

== daftar karantina (dead-letter sebelum ditulis) ==
baris CSV ke-8: order_id='ORD-2026-11-03-006' alasan='customer_name kosong'
baris CSV ke-11: order_id='ORD-2026-11-03-009' alasan='city kosong'

== baris ORD-2026-11-03-004 (duplikat, kemunculan terakhir menang) ==
          order_id customer_name     city  quantity  unit_price  total_price  status
ORD-2026-11-03-004  Dewi Lestari Surabaya         3     75000.0     225000.0 selesai

Aritmetikanya tertutup: 10 baris mentah = 7 fact + 2 karantina + 1 duplikat. Baris ORD-2026-11-03-004 muncul dua kali di sumber dengan versi pertama quantity 2, status diproses; versi terakhir yang hidup di fact dengan total_price 225000. Chapter Deduplikasi Data membahas variasi kebijakan duplikat ini, sementara kebijakan “kemunculan terakhir menang” membuat pemuatan ulang aman seperti dibahas di Idempotent Transformation.

Terakhir, build_fact(clean_df, partition) menyusun kolom fact untuk tabel warehouse dan menambahkan kolom source_partition berisi tanggal partisi. Kolom inilah yang membuat rekonsiliasi pada Bagian 9 tetap tepat untuk data yang datang terlambat. Selanjutnya kita memuat tujuh baris fact ini ke DuckDB.