Data hasil ekspor nyaris tidak pernah bersih. Petugas kasir salah mengetik tanggal, kolom luput diisi, harga tertulis dengan huruf. Program Bagian 1 mempercayai begitu saja isi berkas — dan ketika bertemu data seperti itu, ada dua kemungkinan akibatnya: program mogok di tengah jalan, atau lebih buruk, program diam sambil melaporkan angka yang salah. Bagian ini melengkapi program dengan validasi: setiap baris diperiksa terhadap kontrak data, pelanggaran dikumpulkan sebagai laporan, dan analisis tetap berjalan pada baris yang valid.

Data yang Dikotori Sengaja #

Ganti isi sample.csv dengan versi yang mengandung empat kesalahan realistis:

text
tanggal,produk,kategori,jumlah,harga
2025-06-01,Kopi Susu,Minuman,3,18000
2025-06-01,Roti Cokelat,Makanan,2,12000
2025-06-02,Es Teh Manis,Minuman,5,8000
01/06/2025,Kopi Hitam,Minuman,2,15000
2025-06-03,Nasi Goreng,Makanan,1,25000
2025-06-03,Air Mineral,Minuman,4,5000
2025-06-04,Kopi Susu,Minuman,2,18000
2025-06-04,Keripik Kentang,Camilan,-1,10000
2025-06-05,Es Teh Manis,Minuman,3,8000
2025-06-05,,Makanan,1,20000
2025-06-06,Kopi Hitam,Minuman,4,15000
2025-06-06,Puding Karamel,Camilan,2,14000
2025-06-07,Es Jeruk,Minuman,2

Empat kesalahan sengaja ditanam di baris 5, 9, 11, dan 14 berkas: tanggal berformat 01/06/2025, jumlah -1, kolom produk yang kosong, dan baris terakhir yang kehilangan kolom harga sama sekali. Perhatikan bahwa penomoran mengacu pada baris fisik berkas — baris pertama adalah kepala kolom, jadi data pertama berada di baris 2.

Bagaimana program Bagian 1 menghadapi berkas ini? Jalankan versi lama dan lihat akibatnya:

text
2025-06-04  Keripik Kentang     -1     10000     -10,000
2025-06-05                       1     20000      20,000
... (baris lain tidak ditampilkan)
Traceback (most recent call last):
  File "main.py", line 53, in <module>
    sys.exit(main())
             ^^^^^^
  File "main.py", line 35, in main
    omzet = hitung_omzet(baris)
            ^^^^^^^^^^^^^^^^^^^
  File "main.py", line 15, in hitung_omzet
    return int(baris["jumlah"]) * int(baris["harga"])
                                  ^^^^^^^^^^^^^^^^^^^
TypeError: int() argument must be a string, a bytes-like object or a real number, not 'NoneType'

Dua penyakit muncul sekaligus. Omzet -10,000 dari jumlah -1 lolos tanpa protes dan diam-diam mencemari total omzet; kemudian program akhirnya mogok pada baris terakhir karena DictReader mengisi kolom yang hilang dengan None, dan int(None) menimbulkan TypeError. Program yang baik menolak data seperti ini sejak awal, dengan pesan yang menunjuk baris penyebabnya.

Aturan Validasi #

Kontrak data pada halaman overview diterjemahkan menjadi empat aturan pemeriksaan:

AturanContoh pelanggaranPesan galat
Kelima kolom wajib terisiproduk kosongkolom produk kosong
tanggal berformat YYYY-MM-DD01/06/2025tanggal tidak berformat YYYY-MM-DD
jumlah angka bulat lebih besar dari 0-1, 0, 2.5nilai jumlah ...
harga angka bulat lebih besar dari 01x000nilai harga ...

Pemeriksaan tanggal memanfaatkan datetime.strptime, yang mengurai string menurut pola tertentu dan menolak string yang tidak cocok:

python
>>> from datetime import datetime
>>> datetime.strptime("2025-06-01", "%Y-%m-%d")
datetime.datetime(2025, 6, 1, 0, 0)
>>> datetime.strptime("01/06/2025", "%Y-%m-%d")
Traceback (most recent call last):
  ...
ValueError: time data '01/06/2025' does not match format '%Y-%m-%d'

String 2025-06-01 berhasil diurai menjadi objek datetime, sedangkan 01/06/2025 ditolak dengan ValueError — penolakan itulah yang kita tangkap sebagai tanda baris bermasalah.

Fungsi validasi_baris #

Seluruh aturan di atas dijalankan oleh satu fungsi yang menerima satu baris dan mengembalikan daftar pesan galat. Tambahkan ke main.py:

python
from datetime import datetime

KOLOM_WAJIB = ("tanggal", "produk", "kategori", "jumlah", "harga")


def validasi_baris(baris):
    """Periksa satu baris data dan kembalikan daftar pesan galat."""
    galat = []

    for kolom in KOLOM_WAJIB:
        nilai = baris.get(kolom)
        if nilai is None or nilai.strip() == "":
            galat.append(f"kolom {kolom} kosong")

    if baris.get("tanggal"):
        try:
            datetime.strptime(baris["tanggal"], "%Y-%m-%d")
        except ValueError:
            galat.append("tanggal tidak berformat YYYY-MM-DD")

    for kolom in ("jumlah", "harga"):
        nilai = baris.get(kolom)
        if nilai is None or nilai.strip() == "":
            continue
        try:
            angka = int(nilai)
        except ValueError:
            galat.append(f"nilai {kolom} bukan angka bulat: {nilai!r}")
        else:
            if angka <= 0:
                galat.append(f"nilai {kolom} harus lebih besar dari 0")

    return galat

Mari kita bedah logikanya:

  1. Baris 8 (galat = []): fungsi mengumpulkan semua pesan galat satu baris, bukan berhenti pada pelanggaran pertama. Satu baris boleh punya lebih dari satu masalah, dan semuanya layak dilaporkan.
  2. Baris 10-13 (for kolom in KOLOM_WAJIB:): baris.get(kolom) mengembalikan None bila kolom hilang dari dict — inilah wujud baris pendek dari DictReader. Kedua kondisi, None maupun string kosong "", dicatat sebagai kolom ... kosong.
  3. Baris 15 (if baris.get("tanggal"):): pemeriksaan format hanya berjalan bila tanggal terisi; kolom yang kosong sudah dilaporkan pada langkah sebelumnya, jadi tidak perlu pesan ganda.
  4. Baris 16-19 (try/except ValueError): strptime yang gagal menandakan format salah. Kita tidak meneruskan exception, melainkan menangkapnya dan mengubahnya menjadi pesan galat — exception dipakai sebagai alat deteksi.
  5. Baris 24 (continue): kolom jumlah dan harga yang kosong dilewati pemeriksaan angka dengan alasan yang sama seperti langkah 3.
  6. Baris 25-31 (try/except/else): int(nilai) menguji apakah nilainya angka bulat; blok else hanya berjalan bila konversi berhasil, lalu memeriksa nilainya lebih besar dari 0. String seperti '1x000' gagal di except, sedangkan '-1' lolos konversi tetapi tertangkap di blok else.
  7. Baris 33 (return galat): baris yang valid mengembalikan list kosong — kondisi yang mudah diperiksa dengan if galat:.

Memilah Baris di main #

main() sekarang memilah baris valid dari baris bermasalah sebelum menghitung apa pun:

python
def main():
    if len(sys.argv) != 2:
        print("Pemakaian: python3 main.py <file.csv>")
        return 1

    nama_file = sys.argv[1]
    try:
        data = baca_data(nama_file)
    except FileNotFoundError:
        print(f"File tidak ditemukan: {nama_file}")
        return 1

    valid = []
    galat_semua = []
    for nomor, baris in enumerate(data, start=2):
        galat = validasi_baris(baris)
        if galat:
            for pesan in galat:
                galat_semua.append(f"baris {nomor}: {pesan}")
        else:
            valid.append(baris)

    if galat_semua:
        print("Baris tidak valid (dilewati):")
        for pesan in galat_semua:
            print(f"  - {pesan}")
        print()

    total_omzet = sum(hitung_omzet(baris) for baris in valid)
    print(f"Baris valid       : {len(valid)}")
    print(f"Baris tidak valid : {len(data) - len(valid)}")
    print(f"Total omzet       : Rp{total_omzet:,}")

    return 0

Bagian inti yang baru ada pada baris 13-21:

  1. Baris 13-14 (valid = [] / galat_semua = []): dua penampung — baris yang lulus pemeriksaan, dan pesan galat dari yang tidak lulus.
  2. Baris 15 (enumerate(data, start=2)): nomor urut dimulai dari 2 agar cocok dengan baris fisik berkas, karena baris pertama berkas adalah kepala kolom. Pesan baris 5 bisa langsung dicocokkan pembaca ke editor teks.
  3. Baris 17-21 (if galat: / else:): baris bermasalah tidak dibuang diam-diam — setiap pesannya diawali baris N: lalu dikumpulkan; baris yang bersih masuk ke valid dan analisis berlanjut dengannya.
  4. Baris 29 (sum(...)): generator expression menjumlahkan omzet hanya dari baris valid. Total omzet kini kebal terhadap data rusak.

Menjalankan Program #

Jalankan program pada berkas yang sama:

bash
python3 main.py sample.csv

Output yang Dihasilkan:

text
Baris tidak valid (dilewati):
  - baris 5: tanggal tidak berformat YYYY-MM-DD
  - baris 9: nilai jumlah harus lebih besar dari 0
  - baris 11: kolom produk kosong
  - baris 14: kolom harga kosong

Baris valid       : 9
Baris tidak valid : 4
Total omzet       : Rp311,000

Program tidak lagi mogok. Empat baris bermasalah dilaporkan lengkap dengan nomor baris dan alasannya, sembilan baris valid tetap dianalisis, dan total omzet Rp311,000 hanya menghitung data yang layak hitung. Kode lengkap tahap ini tersedia pada bagian Berkas Sumber di bawah halaman ini. Angka total saja belum menjawab pertanyaan pemilik kedai — pada Bagian 3 kita uraikan data valid menjadi ringkasan per kategori, produk terlaris, dan transaksi tertinggi.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

main.py

Unduh berkas
python
"""Pengolah File CSV — Bagian 2: Memvalidasi data."""

import csv
import sys
from datetime import datetime

KOLOM_WAJIB = ("tanggal", "produk", "kategori", "jumlah", "harga")


def baca_data(nama_file):
    """Baca seluruh baris file CSV dan kembalikan sebagai list of dict."""
    with open(nama_file, newline="", encoding="utf-8") as berkas:
        return list(csv.DictReader(berkas))


def hitung_omzet(baris):
    """Hitung omzet satu baris: jumlah dikali harga."""
    return int(baris["jumlah"]) * int(baris["harga"])


def validasi_baris(baris):
    """Periksa satu baris data dan kembalikan daftar pesan galat."""
    galat = []

    for kolom in KOLOM_WAJIB:
        nilai = baris.get(kolom)
        if nilai is None or nilai.strip() == "":
            galat.append(f"kolom {kolom} kosong")

    if baris.get("tanggal"):
        try:
            datetime.strptime(baris["tanggal"], "%Y-%m-%d")
        except ValueError:
            galat.append("tanggal tidak berformat YYYY-MM-DD")

    for kolom in ("jumlah", "harga"):
        nilai = baris.get(kolom)
        if nilai is None or nilai.strip() == "":
            continue
        try:
            angka = int(nilai)
        except ValueError:
            galat.append(f"nilai {kolom} bukan angka bulat: {nilai!r}")
        else:
            if angka <= 0:
                galat.append(f"nilai {kolom} harus lebih besar dari 0")

    return galat


def main():
    if len(sys.argv) != 2:
        print("Pemakaian: python3 main.py <file.csv>")
        return 1

    nama_file = sys.argv[1]
    try:
        data = baca_data(nama_file)
    except FileNotFoundError:
        print(f"File tidak ditemukan: {nama_file}")
        return 1

    valid = []
    galat_semua = []
    for nomor, baris in enumerate(data, start=2):
        galat = validasi_baris(baris)
        if galat:
            for pesan in galat:
                galat_semua.append(f"baris {nomor}: {pesan}")
        else:
            valid.append(baris)

    if galat_semua:
        print("Baris tidak valid (dilewati):")
        for pesan in galat_semua:
            print(f"  - {pesan}")
        print()

    total_omzet = sum(hitung_omzet(baris) for baris in valid)
    print(f"Baris valid       : {len(valid)}")
    print(f"Baris tidak valid : {len(data) - len(valid)}")
    print(f"Total omzet       : Rp{total_omzet:,}")

    return 0


if __name__ == "__main__":
    sys.exit(main())