Cerita kasus: toko dengan penjualan manual
Kasusnya sengaja saya ambil yang paling umum diberikan dosen: sebuah toko mencatat penjualan barang. Pelanggan datang memesan banyak barang dalam satu transaksi. Setiap barang punya harga yang bisa berbeda antar transaksi karena diskon. Toko ingin tahu siapa pelanggan langganan, barang apa yang paling laku, dan berapa omzet per bulan. Dari satu paragraf ini, tugas Anda adalah menghasilkan struktur database yang menjawab ketiga pertanyaan itu tanpa data ganda.

Identifikasi entitas dan atribut dari bacaan kasus
Cara paling cepat menemukan entitas: tandai semua kata benda yang datanya perlu disimpan lama. Dari kasus di atas ada empat: pelanggan, barang, transaksi penjualan, dan detail item yang dibeli. Kata kerja seperti memesan dan membayar biasanya menjadi relasi, bukan entitas. Setiap entitas lalu diberi atribut: pelanggan punya nama dan telepon, barang punya kode, nama, dan harga dasar.
Titik paling penting di tahap ini: transaksi penjualan dan daftar barang di dalamnya adalah dua entitas berbeda. Kesalahan klasik pemula adalah membuat satu tabel penjualan dengan kolom barang1, barang2, barang3. Struktur seperti itu tidak bisa di-query dengan baik dan pasti ditolak di presentasi. Hubungan many to many antara transaksi dan barang diselesaikan dengan tabel penghubung yang di dunia basis data disebut junction table atau tabel detail.
Diagram ERD penjualan lengkap
Hubungan antar entitasnya: satu pelanggan melakukan banyak transaksi. Satu transaksi memuat banyak baris detail. Satu barang bisa muncul di banyak baris detail dari transaksi berbeda. Kardinalitas berbahasa sederhana: pelanggan ke transaksi itu satu ke banyak, transaksi ke detail itu satu ke banyak, barang ke detail juga satu ke banyak.
pelanggan (id, nama, telepon, alamat)
|
| 1..*
penjualan (id, tanggal, pelanggan_id, total)
|
| 1..*
penjualan_detail (id, penjualan_id, barang_id, qty, harga_satuan, subtotal)
|
| *..1
barang (id, kode, nama, harga_dasar, stok)
Tiga hal yang membuat desain ini benar. Pertama, harga per transaksi disimpan di penjualan_detail sebagai harga_satuan, bukan diambil dari tabel barang saat query, karena harga barang berubah dan riwayat transaksi harus memantulkan harga saat itu. Kedua, subtotal disimpan meskipun bisa dihitung dari qty kali harga_satuan; ini pilihan denormalisasi sadar untuk performa laporan. Ketiga, kolom pelanggan_id di penjualan adalah foreign key yang jadi jembatan relasi satu ke banyak.
Normalisasi tahap demi tahap
Normalisasi biasanya diajarkan sebagai rumus abstrak, padahal sebenarnya ini proses menyingkirkan masalah konkret. Saya tunjukkan prosesnya dari tabel buruk sampai selesai.
UNF: tabel transaksi mentah
| no_nota | tanggal | nama_pelanggan | telepon | barang_t dibeli |
|---------|-------------|----------------|--------------|------------------------------------------|
| N-001 | 2026-10-08 | Rina | 0812xxxx | Kertas A4 x2@25000, Tinta x1@90000 |
| N-002 | 2026-10-08 | Budi | 0813xxxx | Kertas A4 x5@24000 |
Tabel ini punya tiga penyakit: kolom barang_t dibeli berisi banyak nilai (repeating group), informasi pelanggan akan terulang di setiap nota yang sama, dan harga yang berbeda untuk barang sama dipadatkan dalam satu sel. Bentuk ini tidak bisa diindeks, tidak bisa dihitung omzet per barang, dan akan jadi mimpi buruk saat jumlah data naik.
1NF: pecah repeating group
| no_nota | tanggal | nama_pelanggan | telepon | kode_barang | nama_barang | qty | harga |
|---------|------------|----------------|----------|-------------|-------------|-----|-------|
| N-001 | 2026-10-08 | Rina | 0812xxxx | BRG-01 | Kertas A4 | 2 | 25000 |
| N-001 | 2026-10-08 | Rina | 0812xxxx | BRG-02 | Tinta | 1 | 90000 |
| N-002 | 2026-10-08 | Budi | 0813xxxx | BRG-01 | Kertas A4 | 5 | 24000 |
Satu baris sekarang mewakili satu item, atomic, dan bisa di-query. Tapi masih ada redundansi parah: nama dan telepon Rina terulang dua kali, nama barang terulang setiap transaksi. Ubah data pelanggan dan harga dasar barang di masa depan berarti mengubah puluhan baris riwayat. Inilah anomaly update yang akan dijawab bentuk normal berikutnya.
2NF: pisahkan yang bergantung sebagian kunci
Kunci komposit tabel 1NF adalah no_nota plus kode_barang. Kolom nama_pelanggan hanya bergantung pada no_nota, bukan kombinasi keduanya, jadi ia melanggar 2NF. Solusinya pecah jadi tiga tabel: tabel penjualan yang menyimpan pasangan no_nota dengan pelanggan, tabel penjualan_detail yang menyimpan item per nota, dan tabel barang untuk data masternya.
3NF: buang ketergantungan transitif
Sisa masalah di 2NF: misalkan Anda masih menyimpan nama_pelanggan di tabel penjualan, padahal yang jadi kunci adalah pelanggan_id. Nama bergantung pada pelanggan_id, bukan langsung pada no_nota: itu ketergantungan transitif. 3NF memindahkannya ke tabel pelanggan terpisah. Hasil akhirnya persis struktur di bagian diagram: empat tabel, setiap fakta disimpan tepat satu tempat, dan riwayat harga transaksi tetap utuh di detail.
SQL lengkapnya
Dari ERD ke skema MySQL langsung jalan:
CREATE TABLE pelanggan (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(100) NOT NULL,
telepon VARCHAR(20),
alamat TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE barang (
id INT AUTO_INCREMENT PRIMARY KEY,
kode VARCHAR(20) UNIQUE NOT NULL,
nama VARCHAR(150) NOT NULL,
harga_dasar DECIMAL(15,2) NOT NULL,
stok INT DEFAULT 0
);
CREATE TABLE penjualan (
id INT AUTO_INCREMENT PRIMARY KEY,
no_nota VARCHAR(30) UNIQUE NOT NULL,
tanggal DATE NOT NULL,
pelanggan_id INT NOT NULL,
total DECIMAL(15,2) DEFAULT 0,
FOREIGN KEY (pelanggan_id) REFERENCES pelanggan(id)
);
CREATE TABLE penjualan_detail (
id INT AUTO_INCREMENT PRIMARY KEY,
penjualan_id INT NOT NULL,
barang_id INT NOT NULL,
qty INT NOT NULL,
harga_satuan DECIMAL(15,2) NOT NULL,
subtotal DECIMAL(15,2) GENERATED ALWAYS AS (qty * harga_satuan) STORED,
FOREIGN KEY (penjualan_id) REFERENCES penjualan(id),
FOREIGN KEY (barang_id) REFERENCES barang(id)
);
Perhatikan subtotal yang dibuat sebagai generated column: nilainya selalu konsisten karena dihitung database, bukan aplikasi. Ini praktik yang saya pakai di sistem produksi supaya selisih pembulatan antara aplikasi dan laporan tidak pernah terjadi.
Query pertanyaan bisnis yang menjawab kasus
Desain diuji dengan tiga pertanyaan dari cerita kasus tadi. Pelanggan langganan adalah yang transaksinya paling banyak:
-- pelanggan paling aktif
SELECT p.nama, COUNT(*) AS jumlah_transaksi
FROM penjualan s
JOIN pelanggan p ON p.id = s.pelanggan_id
GROUP BY p.id
ORDER BY jumlah_transaksi DESC
LIMIT 10;
-- barang terlaris berdasarkan unit terjual
SELECT b.nama, SUM(d.qty) AS terjual
FROM penjualan_detail d
JOIN barang b ON b.id = d.barang_id
GROUP BY b.id
ORDER BY terjual DESC
LIMIT 10;
-- omzet bulanan tahun berjalan
SELECT DATE_FORMAT(s.tanggal, '%Y-%m') AS bulan,
SUM(d.subtotal) AS omzet
FROM penjualan s
JOIN penjualan_detail d ON d.penjualan_id = s.id
WHERE YEAR(s.tanggal) = 2026
GROUP BY bulan
ORDER BY bulan;
Tiga query ini hanya mungkin ditulis dengan mulus karena strukturnya sudah dinormalisasi. Di tabel UNF, masing-masing butuh manipulasi string dan subquery bertingkat yang lambat dan rapuh.
Kesalahan paling sering saat tugas ERD
Pertama, menyimpan harga saat query dari tabel master. Riwayat transaksi akan berubah setiap kali harga barang diubah, dan laporan bulan lalu jadi tidak cocok dengan buku. Kedua, kolom berjumlah tetap seperti qty1, qty2, qty3; ini repeating group terselubung yang gagal 1NF. Ketiga, menjadikan nama pelanggan sebagai kunci; nama bisa sama dan bisa berubah, kunci harus yang stabil seperti id.
Keempat, relasi many to many tanpa tabel penghubung, ditandai adanya dua foreign key di tabel yang sama menunjuk dua induk. Kelima, lupa unique constraint pada no_nota dan kode barang; tanpa itu, kesalahan input ganda lolos diam-diam sampai laporan keuangan jadi aneh. Terakhir, di presentasi selalu siapkan justifikasi tiap kardinalitas: kalau tidak bisa menjelaskan kenapa pelanggan ke penjualan itu satu ke banyak, diagram Anda hafalan, bukan pemahaman.
Query INSERT, UPDATE, dan DELETE Setiap Tabel
Setelah tabel dibuat, berikut contoh query manipulasi data untuk setiap tabel dalam studi kasus ini. Jalankan query INSERT terlebih dahulu agar data yang menjadi referensi foreign key sudah tersedia.
1. Tabel pelanggan
INSERT INTO pelanggan (id, nama, telepon, alamat) VALUES
(1, 'Budi Santoso', '081234567890', 'Jakarta');
UPDATE pelanggan
SET telepon = '081298765432', alamat = 'Depok'
WHERE id = 1;
DELETE FROM pelanggan
WHERE id = 1;
Pastikan pelanggan tidak masih dipakai oleh tabel penjualan sebelum menjalankan DELETE.
2. Tabel barang
INSERT INTO barang (id, kode, nama, harga_dasar) VALUES
(1, 'BRG-001', 'Keyboard Mechanical', 750000);
UPDATE barang
SET harga_dasar = 725000
WHERE id = 1;
DELETE FROM barang
WHERE id = 1;
Data barang yang sudah tercatat pada penjualan sebaiknya tidak dihapus; gunakan status aktif atau nonaktif jika diperlukan.
3. Tabel penjualan
INSERT INTO penjualan (id, no_nota, tanggal, pelanggan_id) VALUES
(1, 'INV-20261008-001', '2026-10-08', 1);
UPDATE penjualan
SET tanggal = '2026-10-09'
WHERE id = 1;
DELETE FROM penjualan
WHERE id = 1;
Hapus detail penjualan terlebih dahulu jika foreign key menggunakan aturan RESTRICT.
4. Tabel penjualan_detail
INSERT INTO penjualan_detail
(id, penjualan_id, barang_id, qty, harga_satuan, subtotal)
VALUES (1, 1, 1, 2, 750000, 1500000);
UPDATE penjualan_detail
SET qty = 3,
harga_satuan = 725000,
subtotal = qty * harga_satuan
WHERE id = 1;
DELETE FROM penjualan_detail
WHERE id = 1;
Urutan DELETE yang aman
DELETE FROM penjualan_detail WHERE penjualan_id = 1;
DELETE FROM penjualan WHERE id = 1;
-- pelanggan dan barang hanya dihapus jika tidak direferensikan
Dalam aplikasi produksi, jalankan perubahan yang saling terkait dalam transaksi database agar kegagalan satu query tidak meninggalkan data setengah berubah.
Penutup: dari tugas kuliah ke produksi
Studi kasus penjualan ini adalah kerangka yang sama dengan yang dipakai aplikasi nyata; yang membedakan hanya jumlah tabel dan aturan bisnis di sekelilingnya. Kalau Anda paham alurnya dari cerita kasus sampai 3NF, Anda sudah punya fondasi untuk membaca skema database aplikasi mana pun. Untuk memperdalam sisi implementasinya di aplikasi modern, artikel saya tentang WordPress headless dengan Next.js memperlihatkan bagaimana struktur data yang rapi dimanfaatkan oleh aplikasi konsumennya.