Data mentah yang terbaca utuh belum berarti data yang bisa dihitung: 'Rp 85.000' tidak bisa dijumlah, 'Selesai' dan 'selesai' adalah dua string berbeda bagi mesin, dan baris duplikat akan menggelembungkan GMV. Pada bagian ini kita menulis src/clean.py yang menerapkan aturan pembersihan eksplisit untuk kedua sumber. Prinsipnya satu: tidak ada baris yang dibuang tanpa jejak — setiap baris yang ditolak masuk karantina beserta alasannya, sehingga rekonsiliasi mentah = bersih + duplikat + karantina selalu bisa diverifikasi. Materi pendukung ada pada chapter Deduplikasi Data dan Penanganan Data yang Gagal.

Aturan Pembersihan yang Mengikat #

Enam aturan berikut ditulis dalam docstring modul dan diuji satu per satu pada tests/test_clean.py:

  1. Koersi angka: Rp 85.000, Rp65.000, dan 145000 semuanya diuraikan menjadi bilangan bulat.
  2. Koersi tanggal: format YYYY-MM-DD dan DD/MM/YYYY diterima; keduanya dinormalkan ke ISO.
  3. Normalisasi status: huruf kecil dan tanpa spasi; nilai di luar selesai, dibatalkan, dikembalikan ditolak.
  4. Validasi baris: pesanan_id, pelanggan_id, produk_id wajib terisi; kuantitas >= 1; harga_satuan > 0.
  5. Deduplikasi: pesanan ganda disisakan kemunculan terakhir (ekspor ulang menimpa); event ganda disisakan kemunculan pertama (pengiriman ulang dari tracker).
  6. Total dihitung ulang dari kuantitas * harga_satuan - diskon sehingga salah ketik pada kolom total terdeteksi dan diperbaiki.

Koersi dan Validasi Baris #

Fungsi ke_angka dan ke_tanggal adalah garda pertama; keduanya menolak dengan pengecualian BarisDitolak yang membawa alasan:

python
def ke_angka(nilai, kolom):
    """Uraikan nilai rupiah/integer teks menjadi bilangan bulat."""
    teks = (
        str(nilai).strip().lower().replace("rp", "").replace(" ", "").replace(".", "")
    )
    if not teks:
        raise BarisDitolak(f"kolom {kolom} kosong")
    try:
        return int(teks)
    except ValueError:
        raise BarisDitolak(f"kolom {kolom} bukan angka: {nilai!r}") from None
python
def ke_tanggal(nilai):
    """Uraikan tanggal dengan dua format yang dikenal."""
    teks = str(nilai).strip()
    for pola in ("%Y-%m-%d", "%d/%m/%Y"):
        try:
            return datetime.strptime(teks, pola).date()
        except ValueError:
            continue
    raise BarisDitolak(f"format tanggal tidak dikenal: {nilai!r}") from None

Kedua potongan di atas menempati src/clean.py baris 42-52 dan baris 63-72. Mari kita telaah baris-barisnya:

  1. ke_angka baris 45 (str(nilai).strip().lower()...): pembersihan teks dilakukan berantai — spasi di tepi dihapus, huruf dikecilkan, lalu rp, spasi, dan titik ribuan dibuang. Rp 85.000 menjadi 85000.
  2. ke_angka baris 47-52 (raise BarisDitolak): string kosong dan teks non-angka ditolak dengan pesan yang menyebut kolomnya, bukan mengembalikan NaN.
  3. ke_tanggal baris 66 (for pola in (...)): dua format tanggal diuji berurutan; format DD/MM/YYYY ditafsirkan tanggal-bulan-tahun sesuai kebiasaan ekspor lokal.
  4. ke_tanggal baris 72 (from None): rantai pengecualian diputus agar pesan galat menampilkan alasan pembersihan, bukan jejak strptime internal.

Daftar lengkap kode format tanggal yang diterima strptime tercantum pada dokumentasi resmi Python di https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes sehingga menambah format baru cukup satu baris pada daftar pola.

Fungsi _baris_pesanan_bersih kemudian merangkai seluruh aturan untuk satu baris: pengenal diperiksa, angka dikoersi, status divalidasi, dan total dihitung ulang. Baris sehat mengembalikan dict bersih plus penanda apakah total sumbernya salah; baris sakit menaikkan BarisDitolak yang ditangkap pemanggilnya.

Deduplikasi dengan Arah yang Berbeda #

Strategi dedup untuk kedua sumber sengaja berbeda arah, dan alasannya melekat pada karakter sumber:

SumberKunci duplikatDisisakanAlasan
pesananpesanan_id + produk_idkemunculan terakhirekspor ulang berisi koreksi data lama
eventsevent_idkemunculan pertamatracker mengirim ulang event yang sama

Pada pesanan, dedup memakai duplicated(subset=..., keep="last") sehingga baris duplikat parsial ORD-2110 — kemunculan pertamanya memuat total salah ketik 17000 — tersisakan kemunculan terakhir yang benar. Pada events, kumpulan event_id yang sudah dipakai menentukan baris kedua sebagai duplikat.

Menjalankan Pembersihan #

Skrip berikut menjalankan pembersihan penuh pada kedua sumber dan menyimpan hasilnya ke data/staging/:

python
from src import clean, collect

df = collect.baca_pesanan("data/raw/pesanan.csv")
events, karantina_baca = collect.baca_events("data/raw/events.jsonl")

hasil_p = clean.bersihkan_pesanan(df)
hasil_e = clean.bersihkan_events(events, karantina_baca)
ringkasan = clean.simpan_staging("data/staging", hasil_p, hasil_e)

print("ringkasan pembersihan:")
for sumber in ("pesanan", "events"):
    r = ringkasan[sumber]
    print(
        f"  {sumber:8s}: mentah {r['baris_mentah']:3d} -> bersih {r['baris_bersih']:3d}, "
        f"duplikat {r['duplikat']}, karantina {r['karantina']}"
    )
print(f"  total diperbaiki: {ringkasan['pesanan']['total_diperbaiki']} baris")

print("karantina pesanan:")
for _, k in hasil_p["karantina"].iterrows():
    print(f"  {k['pesanan_id']} ({k['produk_id']}): {k['alasan']}")

print("karantina events:")
for k in hasil_e["karantina"]:
    if "baris" in k:
        print(f"  baris {k['baris']}: {k['alasan']}")
    else:
        print(f"  {k['isi'].get('event_id')}: {k['alasan']}")

Output yang Dihasilkan:

text
ringkasan pembersihan:
  pesanan : mentah  38 -> bersih  33, duplikat 2, karantina 3
  events  : mentah 116 -> bersih 112, duplikat 1, karantina 3
  total diperbaiki: 1 baris
karantina pesanan:
  ORD-2199 (PRD-006): kuantitas harus >= 1 (dapat -2)
  ORD-2198 (PRD-002): status tidak dikenal: 'batal'
  ORD-2197 (PRD-005): kolom pelanggan_id kosong
karantina events:
  baris 116: JSON tidak valid
  EVT-0113: event tidak dikenal: 'scrool_produk'
  EVT-0114: pelanggan_id kosong

Rekonsiliasi kedua sumber tertutup rapi: 38 = 33 + 2 + 3 untuk pesanan dan 116 = 112 + 1 + 3 untuk events. Tiga baris karantina pesanan masing-masing melanggar aturan berbeda, dan baris ORD-2107 yang totalnya salah ketik (1600000) diperbaiki menjadi 160000 karena total selalu dihitung ulang dari komponen.

Berkas Staging dan Karantina #

simpan_staging menulis lima berkas ke data/staging/: pesanan_bersih.csv, events_bersih.jsonl, karantina_pesanan.csv, karantina_events.jsonl, dan ringkasan_pembersihan.json. Isi karantina_pesanan.csv memperlihatkan bahwa baris ditolak apa adanya — kolom asli lengkap, hanya ditambah kolom alasan:

csv
pesanan_id,tanggal,pelanggan_id,kanal,produk_id,nama_produk,kategori,kuantitas,harga_satuan,diskon,total,status,alasan
ORD-2199,2025-01-10,PLG-006,web,PRD-006,Kopi Instan Sachet Isi 10,Kopi Instan,-2,32000,0,-64000,selesai,kuantitas harus >= 1 (dapat -2)
ORD-2198,2025-01-11,PLG-008,web,PRD-002,Kopi Robusta Temanggung 200g,Kopi Biji,1,65000,0,65000,batal,status tidak dikenal: 'batal'
ORD-2197,2025-01-12,,web,PRD-005,V60 Dripper Keramik,Alat Seduh,1,175000,0,175000,selesai,kolom pelanggan_id kosong

Berkas ringkasan_pembersihan.json menyarikan angka-angka di atas dan akan dibaca langsung oleh tahap validasi pada Bagian 7. Data bersih sudah siap; pada Bagian 4 kita merancang model analitik yang mengolahnya menjadi tabel siap dashboard.