Toko yang hanya bisa menerima pesanan baru setengah jadi; keputusan belanja stok, harga, dan promosi lahir dari angka yang ditanya kembali. Pada bagian penutup proyek ini kita mengisi penjualan_db dengan dua bulan riwayat penjualan — lengkap dengan status akhir tiap pesanan dan harga snapshot per item — lalu menyusun empat analisis yang dimiliki setiap pemilik toko online: berapa omzet tiap bulan, produk mana yang paling laku, pelanggan siapa yang paling berharga, dan bagaimana sebaran status pesanan. Seluruh query analisis disimpan sebagai berkas siap pakai di folder queries/, dan proyek ditutup dengan satu berkas pemeriksaan yang menguji ulang seluruh fitur secara otomatis.

Menyiapkan Riwayat Dua Bulan #

Kita mulai dari keadaan bersih seperti biasa: dua blok pertama menjalankan pernyataan yang sama dengan database/skema.sql dan database/data-awal.sql (berkas aslinya menyertakan kepala komentar berisi tujuan dan cara pakai):

sql
DROP DATABASE IF EXISTS penjualan_db;
CREATE DATABASE penjualan_db;
USE penjualan_db;

CREATE TABLE pelanggan (
  id INT AUTO_INCREMENT PRIMARY KEY,
  nama VARCHAR(60) NOT NULL,
  email VARCHAR(80) NOT NULL UNIQUE,
  kota VARCHAR(30) NOT NULL
);

CREATE TABLE produk (
  id INT AUTO_INCREMENT PRIMARY KEY,
  sku VARCHAR(15) NOT NULL UNIQUE,
  nama VARCHAR(100) NOT NULL,
  kategori VARCHAR(30) NOT NULL,
  harga DECIMAL(10,2) NOT NULL DEFAULT 0.00,
  stok INT NOT NULL DEFAULT 0,
  CONSTRAINT chk_produk_stok CHECK (stok >= 0)
);

CREATE TABLE pesanan (
  no_pesanan INT AUTO_INCREMENT PRIMARY KEY,
  pelanggan_id INT NOT NULL,
  tanggal DATE NOT NULL,
  status ENUM('baru','dibayar','dikirim','selesai','dibatalkan') NOT NULL DEFAULT 'baru',
  CONSTRAINT fk_pesanan_pelanggan
    FOREIGN KEY (pelanggan_id) REFERENCES pelanggan(id)
);

CREATE TABLE detail_pesanan (
  pesanan_id INT NOT NULL,
  produk_id INT NOT NULL,
  jumlah INT NOT NULL,
  harga_satuan DECIMAL(10,2) NOT NULL,
  PRIMARY KEY (pesanan_id, produk_id),
  CONSTRAINT chk_detail_jumlah CHECK (jumlah > 0),
  CONSTRAINT fk_detail_pesanan
    FOREIGN KEY (pesanan_id) REFERENCES pesanan(no_pesanan),
  CONSTRAINT fk_detail_produk
    FOREIGN KEY (produk_id) REFERENCES produk(id)
);

Output yang Dihasilkan:

text
Query OK, 0 rows affected, 1 warning (0.00 sec)

Query OK, 1 row affected (0.00 sec)

Database changed
Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)
sql
INSERT INTO pelanggan (nama, email, kota) VALUES
  ('Budi Santoso', '[email protected]', 'Bandung'),
  ('Siti Rahmawati', '[email protected]', 'Jakarta'),
  ('Andi Wijaya', '[email protected]', 'Surabaya'),
  ('Dewi Lestari', '[email protected]', 'Yogyakarta'),
  ('Rina Marlina', '[email protected]', 'Bandung'),
  ('Tono Sutrisno', '[email protected]', 'Medan'),
  ('Sari Puspita', '[email protected]', 'Semarang'),
  ('Joko Prasetyo', '[email protected]', 'Denpasar');

INSERT INTO produk (sku, nama, kategori, harga, stok) VALUES
  ('TS-001', 'Kaos Polos Katun Combed 30s', 'Pakaian', 45000.00, 50),
  ('TS-002', 'Kemeja Flanel Lengan Panjang', 'Pakaian', 95000.00, 30),
  ('JA-001', 'Jaket Hoodie Zipper Unisex', 'Pakaian', 125000.00, 20),
  ('CP-001', 'Celana Chino Slim Fit', 'Pakaian', 85000.00, 35),
  ('SN-001', 'Sneakers Kanvas Klasik', 'Sepatu', 120000.00, 25),
  ('SN-002', 'Sandal Gunung Dewasa', 'Sepatu', 65000.00, 40),
  ('TA-001', 'Tas Ransel Laptop 15.6 inch', 'Tas', 145000.00, 15),
  ('TA-002', 'Totebag Kanvas Belajar', 'Tas', 35000.00, 60),
  ('AK-001', 'Topi Baseball Hitam', 'Aksesoris', 28000.00, 45),
  ('AK-002', 'Ikat Pinggang Kulit Sintetis', 'Aksesoris', 55000.00, 25);

Output yang Dihasilkan:

text
Query OK, 8 rows affected (0.00 sec)
Records: 8  Duplicates: 0  Warnings: 0

Query OK, 10 rows affected (0.00 sec)
Records: 10  Duplicates: 0  Warnings: 0

Di atas titik awal itu kita tumpahkan dua belas pesanan sepanjang Januari–Februari 2026. Nomor pesanan ditulis eksplisit supaya analisis mudah dirujuk; dua pesanan (3 dan 7) sengaja berakhir dibatalkan, satu pesanan (12) masih baru, dan sisanya tersebar di tahap dibayar, dikirim, dan selesai:

sql
INSERT INTO pesanan (no_pesanan, pelanggan_id, tanggal, status) VALUES
  (1, 2, '2026-01-03', 'selesai'),
  (2, 1, '2026-01-05', 'selesai'),
  (3, 4, '2026-01-08', 'dibatalkan'),
  (4, 5, '2026-01-12', 'selesai'),
  (5, 3, '2026-01-15', 'selesai'),
  (6, 2, '2026-01-20', 'selesai'),
  (7, 6, '2026-01-25', 'dibatalkan'),
  (8, 7, '2026-02-02', 'selesai'),
  (9, 1, '2026-02-06', 'dikirim'),
  (10, 4, '2026-02-10', 'dibayar'),
  (11, 8, '2026-02-15', 'dikirim'),
  (12, 5, '2026-02-20', 'baru');

INSERT INTO detail_pesanan (pesanan_id, produk_id, jumlah, harga_satuan) VALUES
  (1, 1, 2, 40000.00), (1, 8, 1, 35000.00),
  (2, 3, 1, 125000.00), (2, 4, 1, 85000.00),
  (3, 5, 1, 120000.00),
  (4, 1, 3, 40000.00), (4, 9, 2, 28000.00),
  (5, 4, 2, 85000.00),
  (6, 3, 1, 125000.00),
  (7, 6, 2, 65000.00),
  (8, 1, 2, 45000.00), (8, 8, 2, 35000.00),
  (9, 7, 1, 145000.00), (9, 10, 1, 55000.00),
  (10, 1, 1, 45000.00), (10, 9, 1, 28000.00),
  (11, 5, 1, 120000.00), (11, 2, 1, 95000.00),
  (12, 4, 1, 85000.00);

Output yang Dihasilkan:

text
Query OK, 12 rows affected (0.00 sec)
Records: 12  Duplicates: 0  Warnings: 0

Query OK, 19 rows affected (0.00 sec)
Records: 19  Duplicates: 0  Warnings: 0

Perhatikan kolom harga_satuan pada kaos polos (TS-001): pesanan Januari (1, 4) tercatat 40000.00 sedangkan pesanan Februari (8, 10) tercatat 45000.00 — harga kini di produk adalah 45000.00. Snapshot harga inilah yang membuat omzet historis tetap benar: kenaikan harga 1 Februari tidak menulis ulang nilai pesanan bulan lalu.

Riwayat ini juga merefleksikan alur Bagian 3: setiap pesanan aktif mengurangi stok, sedangkan pesanan dibatalkan tidak. Daripada menulis UPDATE satu per satu, kita sinkronkan sekaligus lewat agregasi — stok berkurang sebesar total unit pesanan yang tidak dibatalkan:

sql
UPDATE produk p
JOIN (
  SELECT d.produk_id, SUM(d.jumlah) AS terjual
  FROM detail_pesanan d
  JOIN pesanan pe ON pe.no_pesanan = d.pesanan_id
  WHERE pe.status <> 'dibatalkan'
  GROUP BY d.produk_id
) jual ON jual.produk_id = p.id
SET p.stok = p.stok - jual.terjual;

Output yang Dihasilkan:

text
Query OK, 9 rows affected (0.00 sec)
Rows matched: 9  Changed: 9  Warnings: 0

Sembilan dari sepuluh produk tercatat terjual — sandal gunung tidak termasuk karena satu-satunya pesanannya (nomor 7) dibatalkan. Sisa stoknya kita baca sekali jalan:

sql
SELECT id, sku, stok FROM produk ORDER BY id;

Output yang Dihasilkan:

text
+----+--------+------+
| id | sku    | stok |
+----+--------+------+
|  1 | TS-001 |   42 |
|  2 | TS-002 |   29 |
|  3 | JA-001 |   18 |
|  4 | CP-001 |   31 |
|  5 | SN-001 |   24 |
|  6 | SN-002 |   40 |
|  7 | TA-001 |   14 |
|  8 | TA-002 |   57 |
|  9 | AK-001 |   42 |
| 10 | AK-002 |   24 |
+----+--------+------+
10 rows in set (0.00 sec)

Stok kaos turun dari 50 menjadi 42 (delapan terjual), jaket dari 20 menjadi 18, dan sandal tetap 40. Angka-angka ini menjadi acuan kebenaran bagi seluruh analisis berikutnya.

Analisis Omzet per Bulan #

Pertanyaan pertama pemilik toko: berapa pendapatan tiap bulan? Omzet dihitung dari jumlah * harga_satuan pada pesanan yang benar-benar menghasilkan uang — status dibayar, dikirim, atau selesai. Pesanan baru belum pasti dibayar dan pesanan dibatalkan pasti tidak, jadi keduanya dikecualikan. Isi queries/omzet-per-bulan.sql:

sql
SELECT DATE_FORMAT(p.tanggal, '%Y-%m') AS bulan,
       SUM(d.jumlah * d.harga_satuan) AS omzet
FROM pesanan p
JOIN detail_pesanan d ON d.pesanan_id = p.no_pesanan
WHERE p.status IN ('dibayar','dikirim','selesai')
GROUP BY DATE_FORMAT(p.tanggal, '%Y-%m')
ORDER BY bulan;

Output yang Dihasilkan:

text
+---------+-----------+
| bulan   | omzet     |
+---------+-----------+
| 2026-01 | 796000.00 |
| 2026-02 | 648000.00 |
+---------+-----------+
2 rows in set (0.00 sec)

Januari menghasilkan omzet 796000.00 dan Februari 648000.00. DATE_FORMAT(tanggal, '%Y-%m') memampatkan tanggal menjadi label bulan sehingga GROUP BY mengelompokkan per bulan; daftar status pada WHERE menentukan definisi omzet yang kita pilih. Tanggal pada data seluruhnya literal, sehingga laporan ini menghasilkan angka yang sama setiap kali dijalankan ulang — sifat yang wajib untuk laporan yang diarsipkan.

Analisis Produk Terlaris #

Pertanyaan kedua: produk mana yang paling laku? Kali ini kita menghitung unit terjual dari pesanan aktif — pesanan baru ikut dihitung karena permintaannya nyata, hanya pembayarannya yang belum masuk; pesanan dibatalkan tetap dikecualikan. Isi queries/produk-terlaris.sql:

sql
SELECT p.sku, p.nama,
       COALESCE(SUM(dj.jumlah), 0) AS total_terjual,
       RANK() OVER (ORDER BY COALESCE(SUM(dj.jumlah), 0) DESC) AS peringkat
FROM produk p
LEFT JOIN (
  SELECT d.produk_id, d.jumlah
  FROM detail_pesanan d
  JOIN pesanan pe ON pe.no_pesanan = d.pesanan_id
  WHERE pe.status <> 'dibatalkan'
) dj ON dj.produk_id = p.id
GROUP BY p.id, p.sku, p.nama
ORDER BY total_terjual DESC, p.sku;

Output yang Dihasilkan:

text
+--------+------------------------------+---------------+-----------+
| sku    | nama                         | total_terjual | peringkat |
+--------+------------------------------+---------------+-----------+
| TS-001 | Kaos Polos Katun Combed 30s  |             8 |         1 |
| CP-001 | Celana Chino Slim Fit        |             4 |         2 |
| AK-001 | Topi Baseball Hitam          |             3 |         3 |
| TA-002 | Totebag Kanvas Belajar       |             3 |         3 |
| JA-001 | Jaket Hoodie Zipper Unisex   |             2 |         5 |
| AK-002 | Ikat Pinggang Kulit Sintetis |             1 |         6 |
| SN-001 | Sneakers Kanvas Klasik       |             1 |         6 |
| TA-001 | Tas Ransel Laptop 15.6 inch  |             1 |         6 |
| TS-002 | Kemeja Flanel Lengan Panjang |             1 |         6 |
| SN-002 | Sandal Gunung Dewasa         |             0 |        10 |
+--------+------------------------------+---------------+-----------+
10 rows in set (0.00 sec)

Tiga keputusan pada query ini menentukan mutu analisisnya:

  1. Derived table dj menyaring baris detail milik pesanan aktif lebih dulu, baru di-LEFT JOIN ke produk. Susunan ini menjaga produk tanpa penjualan tetap tampil dengan angka nol — sandal gunung muncul di peringkat 10 meski satu-satunya pesanannya batal.
  2. COALESCE(SUM(dj.jumlah), 0) mengubah SUM yang menghasilkan NULL (produk tanpa baris detail aktif) menjadi 0, sehingga ORDER BY dan RANK() membandingkan angka, bukan NULL.
  3. RANK() OVER (ORDER BY ... DESC) memberi peringkat tanpa memutus seri: topi dan totebag sama-sama terjual 3 dan keduanya menempati peringkat 3, lalu peringkat berikutnya melompat ke 5. Urutan tampil ditata terpisah lewat ORDER BY total_terjual DESC, p.sku sehingga hasil selalu deterministik.

Kaos polos unggul jauh dengan 8 unit — produk murah yang laku cepat — disusul celana chino dengan 4 unit. Temuan menarik justru di posisi bawah: kemeja, sneaker, tas ransel, dan ikat pinggang masing-masing hanya menjual 1 unit. Data seperti ini menuntun keputusan halaman etalase: produk peringkat atas layak mendapat posisi paling atas.

Analisis Pelanggan Teratas #

Pertanyaan ketiga: pelanggan siapa yang paling berharga? Dua metrik dihitung sekaligus — banyaknya pesanan aktif (COUNT) dan total nilai belanja (SUM dari jumlah * harga_satuan). Isi queries/pelanggan-teratas.sql:

sql
SELECT pl.nama, pl.kota,
       COUNT(DISTINCT p.no_pesanan) AS jumlah_pesanan,
       COALESCE(SUM(d.jumlah * d.harga_satuan), 0) AS total_belanja
FROM pelanggan pl
LEFT JOIN pesanan p ON p.pelanggan_id = pl.id AND p.status <> 'dibatalkan'
  LEFT JOIN detail_pesanan d ON d.pesanan_id = p.no_pesanan
GROUP BY pl.id, pl.nama, pl.kota
ORDER BY total_belanja DESC, pl.nama;

Output yang Dihasilkan:

text
+----------------+------------+----------------+---------------+
| nama           | kota       | jumlah_pesanan | total_belanja |
+----------------+------------+----------------+---------------+
| Budi Santoso   | Bandung    |              2 |     410000.00 |
| Rina Marlina   | Bandung    |              2 |     261000.00 |
| Siti Rahmawati | Jakarta    |              2 |     240000.00 |
| Joko Prasetyo  | Denpasar   |              1 |     215000.00 |
| Andi Wijaya    | Surabaya   |              1 |     170000.00 |
| Sari Puspita   | Semarang   |              1 |     160000.00 |
| Dewi Lestari   | Yogyakarta |              1 |      73000.00 |
| Tono Sutrisno  | Medan      |              0 |          0.00 |
+----------------+------------+----------------+---------------+
8 rows in set (0.00 sec)

Kondisi p.status <> 'dibatalkan' diletakkan di klausa ON join, bukan di WHERE — penempatan yang disengaja: kondisi di WHERE akan membuang pelanggan tanpa pesanan aktif dari hasil (barisnya terfilter habis), sedangkan kondisi di ON tetap menjaga Tono tampil dengan nol pesanan. Query juga memakai COUNT(DISTINCT p.no_pesanan), bukan COUNT(p.no_pesanan): join kedua menggandakan baris pelanggan yang pesanannya memuat beberapa produk — Budi dengan dua pesanan dua produk muncul sebagai empat baris — sehingga COUNT tanpa DISTINCT akan menghitung baris rincian, bukan jumlah pesanan. Budi Santoso memimpin dengan dua pesanan senilai 410000.00, disusul Rina dan Siti. Kolom kota ikut ditampilkan karena gratis sudah tersedia — pemilik toko tinggal menggabungkan daftar ini dengan strategi ongkir per kota.

Analisis Distribusi Status #

Pertanyaan keempat: bagaimana kesehatan alur pesanan toko? Sebaran status menjawabnya — banyaknya pesanan yang berada di tiap tahap daur hidup. Isi queries/distribusi-status.sql:

sql
SELECT status, COUNT(*) AS jumlah_pesanan
FROM pesanan
GROUP BY status
ORDER BY status;

Output yang Dihasilkan:

text
+------------+----------------+
| status     | jumlah_pesanan |
+------------+----------------+
| baru       |              1 |
| dibayar    |              1 |
| dikirim    |              2 |
| selesai    |              6 |
| dibatalkan |              2 |
+------------+----------------+
5 rows in set (0.00 sec)

Enam pesanan tuntas sampai selesai, dua dikirim, satu dibayar, satu baru, dan dua dibatalkan — dua belas pesanan semua terhitung. GROUP BY status pada kolom ENUM menghasilkan satu baris per status yang muncul di data; status yang belum pernah dipakai tidak tampil. Angka dibatalkan yang sebesar 2 dari 12 (seperenam pesanan) patut dicermati: tingkat pembatalan adalah indikator dini masalah harga, stok, atau kecepatan respon toko.

Pemeriksaan Akhir Proyek #

Seluruh fitur proyek — skema, pembuatan pesanan atomik, penghitungan total, perubahan status, pembatalan, dan analisis — kini terkumpul dalam satu berkas uji: tests/pemeriksaan.sql. Berkas ini mandiri: membangun skenario uji kecilnya sendiri, memverifikasi tiap fitur lewat query berlabel ekspektasi, lalu membersihkan database latihan. Kita jalankan dari direktori akar proyek:

bash
-- @test-bash
mysql -h 127.0.0.1 -P 3306 -u root < content/mysql/proyek/database-penjualan/tests/pemeriksaan.sql

Output yang Dihasilkan:

text
sku	stok
TS-001	18
AK-001	9
pesanan_id	total
1	118000.00
no_pesanan	status
1	dibayar
no_pesanan	status
2	dibatalkan
sku	stok
TA-002	15
no_pesanan	status
3	selesai
bulan	omzet
2026-01	118000.00
2026-02	45000.00
sku	total_terjual	peringkat
TS-001	3	1
AK-001	1	2
TA-002	0	3
status	jumlah_pesanan
dibayar	1
selesai	1
dibatalkan	1

Hasil pada output tersebut adalah delapan pemeriksaan berurutan: pesanan dibuat atomik dengan stok berkurang tepat (1), total pesanan 118000.00 terhitung dari rinciannya (2), status naik dari baru ke dibayar (3), pembatalan memulihkan stok totebag ke 15 (4), pesanan ketiga tuntas selesai (5), omzet Januari dan Februari terpisah dengan angka tepat (6), produk terlaris terperingkat benar dengan RANK() (7), dan sebaran status memuat tiga status satu-satu (8). Karena berkas ini membangun skenarionya sendiri, pemeriksaan bisa dijalankan kapan pun — bahkan setelah database utama dibersihkan.

Membersihkan Database Latihan #

Proyek selesai dan database latihan tidak lagi diperlukan. Blok terakhir proyek ini membuangnya dengan sopan:

sql
DROP DATABASE IF EXISTS penjualan_db;

Output yang Dihasilkan:

text
Query OK, 4 rows affected (0.00 sec)

IF EXISTS membuat pernyataan ini aman dijalankan ulang — bila database sudah tidak ada, MySQL hanya mengeluarkan peringatan, bukan error.

Ringkasan Proyek #

Empat bagian perjalanan kita menghasilkan sistem yang utuh dan teruji:

  • Bagian 1 — kebutuhan toko online dianalisis menjadi empat tabel: pelanggan dengan email unik, produk dengan harga DECIMAL dan CHECK stok, pesanan dengan ENUM lima status, dan detail_pesanan dengan PK komposit serta kolom harga_satuan snapshot.
  • Bagian 2 — katalog dan pelanggan terisi dari database/data-awal.sql; operasi CRUD harian berjalan, dan constraint terbukti menolak email ganda serta pesanan pelanggan fiktif.
  • Bagian 3 — pembuatan pesanan berjalan atomik: INSERT pesanan + INSERT detail + UPDATE stok dalam satu START TRANSACTION ... COMMIT; total dihitung dari rincian, status naik tahap dengan guard, pembatalan memulihkan stok, dan ROLLBACK menyelamatkan salah catat.
  • Bagian 4 — empat analisis siap pakai di queries/ memakai DATE_FORMAT, agregat, RANK(), dan join bersyarat, plus berkas pemeriksaan mandiri di tests/pemeriksaan.sql.

Dengan ini tiga proyek berturut-turut — perpustakaan, inventaris, dan penjualan — telah membentuk pola yang sama: rancang skema dengan constraint, isi data master, operasikan alur inti dalam transaksi, lalu gali wawasan lewat query analitik. Pola ini siap kita bawa ke domain-domain berikutnya, dari pencatatan keuangan pribadi hingga analisis data yang lebih mendalam.

Referensi #

Berkas Sumber

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

database/data-awal.sql

Unduh berkas
sql
-- data-awal.sql — Proyek Database Penjualan
-- Tujuan: mengisi data contoh deterministik (10 produk dan 8 pelanggan
--   fiktif) sebagai titik awal konsisten untuk seluruh bagian proyek.
-- Cara pakai: jalankan setelah skema.sql:
--   mysql -u root -p penjualan_db < database/data-awal.sql

INSERT INTO pelanggan (nama, email, kota) VALUES
  ('Budi Santoso', '[email protected]', 'Bandung'),
  ('Siti Rahmawati', '[email protected]', 'Jakarta'),
  ('Andi Wijaya', '[email protected]', 'Surabaya'),
  ('Dewi Lestari', '[email protected]', 'Yogyakarta'),
  ('Rina Marlina', '[email protected]', 'Bandung'),
  ('Tono Sutrisno', '[email protected]', 'Medan'),
  ('Sari Puspita', '[email protected]', 'Semarang'),
  ('Joko Prasetyo', '[email protected]', 'Denpasar');

INSERT INTO produk (sku, nama, kategori, harga, stok) VALUES
  ('TS-001', 'Kaos Polos Katun Combed 30s', 'Pakaian', 45000.00, 50),
  ('TS-002', 'Kemeja Flanel Lengan Panjang', 'Pakaian', 95000.00, 30),
  ('JA-001', 'Jaket Hoodie Zipper Unisex', 'Pakaian', 125000.00, 20),
  ('CP-001', 'Celana Chino Slim Fit', 'Pakaian', 85000.00, 35),
  ('SN-001', 'Sneakers Kanvas Klasik', 'Sepatu', 120000.00, 25),
  ('SN-002', 'Sandal Gunung Dewasa', 'Sepatu', 65000.00, 40),
  ('TA-001', 'Tas Ransel Laptop 15.6 inch', 'Tas', 145000.00, 15),
  ('TA-002', 'Totebag Kanvas Belajar', 'Tas', 35000.00, 60),
  ('AK-001', 'Topi Baseball Hitam', 'Aksesoris', 28000.00, 45),
  ('AK-002', 'Ikat Pinggang Kulit Sintetis', 'Aksesoris', 55000.00, 25);

database/skema.sql

Unduh berkas
sql
-- skema.sql — Proyek Database Penjualan
-- Tujuan: membuat database penjualan_db beserta keempat tabelnya
--   (pelanggan, produk, pesanan, detail_pesanan) lengkap dengan primary key,
--   kolom unik email/sku, foreign key bernama, ENUM status, PK komposit,
--   dan check constraint stok/jumlah.
-- Cara pakai: mysql -u root -p < database/skema.sql

DROP DATABASE IF EXISTS penjualan_db;
CREATE DATABASE penjualan_db;
USE penjualan_db;

-- Pelanggan toko online; email unik sebagai identitas kontak.
CREATE TABLE pelanggan (
  id INT AUTO_INCREMENT PRIMARY KEY,
  nama VARCHAR(60) NOT NULL,
  email VARCHAR(80) NOT NULL UNIQUE,
  kota VARCHAR(30) NOT NULL
);

-- Katalog produk; harga DECIMAL agar nilai rupiah pasti.
CREATE TABLE produk (
  id INT AUTO_INCREMENT PRIMARY KEY,
  sku VARCHAR(15) NOT NULL UNIQUE,
  nama VARCHAR(100) NOT NULL,
  kategori VARCHAR(30) NOT NULL,
  harga DECIMAL(10,2) NOT NULL DEFAULT 0.00,
  stok INT NOT NULL DEFAULT 0,
  CONSTRAINT chk_produk_stok CHECK (stok >= 0)
);

-- Satu baris per pesanan; status mengikuti daur hidup pesanan.
CREATE TABLE pesanan (
  no_pesanan INT AUTO_INCREMENT PRIMARY KEY,
  pelanggan_id INT NOT NULL,
  tanggal DATE NOT NULL,
  status ENUM('baru','dibayar','dikirim','selesai','dibatalkan') NOT NULL DEFAULT 'baru',
  CONSTRAINT fk_pesanan_pelanggan
    FOREIGN KEY (pelanggan_id) REFERENCES pelanggan(id)
);

-- Rincian produk per pesanan; harga_satuan adalah snapshot harga saat
-- pesanan dibuat, bukan harga terbaru di tabel produk.
CREATE TABLE detail_pesanan (
  pesanan_id INT NOT NULL,
  produk_id INT NOT NULL,
  jumlah INT NOT NULL,
  harga_satuan DECIMAL(10,2) NOT NULL,
  PRIMARY KEY (pesanan_id, produk_id),
  CONSTRAINT chk_detail_jumlah CHECK (jumlah > 0),
  CONSTRAINT fk_detail_pesanan
    FOREIGN KEY (pesanan_id) REFERENCES pesanan(no_pesanan),
  CONSTRAINT fk_detail_produk
    FOREIGN KEY (produk_id) REFERENCES produk(id)
);

queries/distribusi-status.sql

Unduh berkas
sql
-- distribusi-status.sql — Proyek Database Penjualan
-- Tujuan: menghitung banyaknya pesanan pada tiap status daur hidup
--   (baru, dibayar, dikirim, selesai, dibatalkan).
-- Cara pakai: mysql -u root -p penjualan_db < queries/distribusi-status.sql

SELECT status, COUNT(*) AS jumlah_pesanan
FROM pesanan
GROUP BY status
ORDER BY status;

queries/omzet-per-bulan.sql

Unduh berkas
sql
-- omzet-per-bulan.sql — Proyek Database Penjualan
-- Tujuan: menjumlahkan omzet per bulan dari rincian pesanan yang statusnya
--   sudah pasti menghasilkan uang (dibayar, dikirim, selesai) memakai
--   DATE_FORMAT atas tanggal literal dan harga_satuan snapshot.
-- Cara pakai: mysql -u root -p penjualan_db < queries/omzet-per-bulan.sql

SELECT DATE_FORMAT(p.tanggal, '%Y-%m') AS bulan,
       SUM(d.jumlah * d.harga_satuan) AS omzet
FROM pesanan p
JOIN detail_pesanan d ON d.pesanan_id = p.no_pesanan
WHERE p.status IN ('dibayar','dikirim','selesai')
GROUP BY DATE_FORMAT(p.tanggal, '%Y-%m')
ORDER BY bulan;

queries/pelanggan-teratas.sql

Unduh berkas
sql
-- pelanggan-teratas.sql — Proyek Database Penjualan
-- Tujuan: menampilkan pelanggan berdasarkan jumlah pesanan aktif dan total
--   belanja (jumlah x harga_satuan snapshot); pesanan dibatalkan dikecualikan.
--   Pelanggan tanpa transaksi tetap tampil dengan nilai nol.
-- Cara pakai: mysql -u root -p penjualan_db < queries/pelanggan-teratas.sql

SELECT pl.nama, pl.kota,
       COUNT(DISTINCT p.no_pesanan) AS jumlah_pesanan,
       COALESCE(SUM(d.jumlah * d.harga_satuan), 0) AS total_belanja
FROM pelanggan pl
LEFT JOIN pesanan p ON p.pelanggan_id = pl.id AND p.status <> 'dibatalkan'
LEFT JOIN detail_pesanan d ON d.pesanan_id = p.no_pesanan
GROUP BY pl.id, pl.nama, pl.kota
ORDER BY total_belanja DESC, pl.nama;

queries/produk-terlaris.sql

Unduh berkas
sql
-- produk-terlaris.sql — Proyek Database Penjualan
-- Tujuan: memeringkat produk berdasarkan total unit terjual (pesanan aktif,
--   termasuk yang berstatus baru) memakai window function RANK().
--   Produk tanpa penjualan tetap tampil dengan angka nol.
-- Cara pakai: mysql -u root -p penjualan_db < queries/produk-terlaris.sql

SELECT p.sku, p.nama,
       COALESCE(SUM(dj.jumlah), 0) AS total_terjual,
       RANK() OVER (ORDER BY COALESCE(SUM(dj.jumlah), 0) DESC) AS peringkat
FROM produk p
LEFT JOIN (
  SELECT d.produk_id, d.jumlah
  FROM detail_pesanan d
  JOIN pesanan pe ON pe.no_pesanan = d.pesanan_id
  WHERE pe.status <> 'dibatalkan'
) dj ON dj.produk_id = p.id
GROUP BY p.id, p.sku, p.nama
ORDER BY total_terjual DESC, p.sku;

tests/pemeriksaan.sql

Unduh berkas
sql
-- pemeriksaan.sql — Proyek Database Penjualan
-- Tujuan: pemeriksaan akhir seluruh fitur proyek (pembuatan pesanan atomik,
--   penghitungan total, perubahan status, pembatalan yang memulihkan stok,
--   omzet per bulan, produk terlaris, dan distribusi status).
--   Berkas ini MANDIRI: membangun skenario uji kecilnya sendiri,
--   lalu membersihkan database setelah selesai.
-- Cara pakai: mysql -u root -p < tests/pemeriksaan.sql
-- Ekspektasi tiap hasil ditulis sebagai komentar di atas query terkait.

DROP DATABASE IF EXISTS penjualan_db;
CREATE DATABASE penjualan_db;
USE penjualan_db;

CREATE TABLE pelanggan (
  id INT AUTO_INCREMENT PRIMARY KEY,
  nama VARCHAR(60) NOT NULL,
  email VARCHAR(80) NOT NULL UNIQUE,
  kota VARCHAR(30) NOT NULL
);
CREATE TABLE produk (
  id INT AUTO_INCREMENT PRIMARY KEY,
  sku VARCHAR(15) NOT NULL UNIQUE,
  nama VARCHAR(100) NOT NULL,
  kategori VARCHAR(30) NOT NULL,
  harga DECIMAL(10,2) NOT NULL DEFAULT 0.00,
  stok INT NOT NULL DEFAULT 0,
  CONSTRAINT chk_produk_stok CHECK (stok >= 0)
);
CREATE TABLE pesanan (
  no_pesanan INT AUTO_INCREMENT PRIMARY KEY,
  pelanggan_id INT NOT NULL,
  tanggal DATE NOT NULL,
  status ENUM('baru','dibayar','dikirim','selesai','dibatalkan') NOT NULL DEFAULT 'baru',
  CONSTRAINT fk_pesanan_pelanggan
    FOREIGN KEY (pelanggan_id) REFERENCES pelanggan(id)
);
CREATE TABLE detail_pesanan (
  pesanan_id INT NOT NULL,
  produk_id INT NOT NULL,
  jumlah INT NOT NULL,
  harga_satuan DECIMAL(10,2) NOT NULL,
  PRIMARY KEY (pesanan_id, produk_id),
  CONSTRAINT chk_detail_jumlah CHECK (jumlah > 0),
  CONSTRAINT fk_detail_pesanan
    FOREIGN KEY (pesanan_id) REFERENCES pesanan(no_pesanan),
  CONSTRAINT fk_detail_produk
    FOREIGN KEY (produk_id) REFERENCES produk(id)
);

INSERT INTO pelanggan (nama, email, kota) VALUES
  ('Budi Santoso', '[email protected]', 'Bandung'),
  ('Siti Rahmawati', '[email protected]', 'Jakarta'),
  ('Andi Wijaya', '[email protected]', 'Surabaya');
INSERT INTO produk (sku, nama, kategori, harga, stok) VALUES
  ('TS-001', 'Kaos Polos Katun Combed 30s', 'Pakaian', 45000.00, 20),
  ('TA-002', 'Totebag Kanvas Belajar', 'Tas', 35000.00, 15),
  ('AK-001', 'Topi Baseball Hitam', 'Aksesoris', 28000.00, 10);

-- Pemeriksaan 1: pesanan dibuat atomik - rincian masuk, stok berkurang.
START TRANSACTION;
INSERT INTO pesanan (pelanggan_id, tanggal, status)
  VALUES (2, '2026-01-10', 'baru');
INSERT INTO detail_pesanan (pesanan_id, produk_id, jumlah, harga_satuan) VALUES
  (LAST_INSERT_ID(), 1, 2, 45000.00),
  (LAST_INSERT_ID(), 3, 1, 28000.00);
UPDATE produk SET stok = stok - 2 WHERE id = 1;
UPDATE produk SET stok = stok - 1 WHERE id = 3;
COMMIT;
-- Ekspektasi: TS-001 stok 18, AK-001 stok 9
SELECT sku, stok FROM produk WHERE id IN (1, 3) ORDER BY id;

-- Pemeriksaan 2: total pesanan dihitung dari rincian (SUM jumlah x harga).
-- Ekspektasi: pesanan 1 total 118000.00
SELECT d.pesanan_id, SUM(d.jumlah * d.harga_satuan) AS total
FROM detail_pesanan d
GROUP BY d.pesanan_id
ORDER BY d.pesanan_id;

-- Pemeriksaan 3: status pesanan berubah dari baru menjadi dibayar.
UPDATE pesanan SET status = 'dibayar' WHERE no_pesanan = 1 AND status = 'baru';
-- Ekspektasi: pesanan 1 status dibayar
SELECT no_pesanan, status FROM pesanan WHERE no_pesanan = 1;

-- Pemeriksaan 4: pembatalan pesanan mengembalikan status dan stok.
START TRANSACTION;
INSERT INTO pesanan (pelanggan_id, tanggal, status)
  VALUES (3, '2026-01-15', 'baru');
INSERT INTO detail_pesanan (pesanan_id, produk_id, jumlah, harga_satuan)
  VALUES (2, 2, 1, 35000.00);
UPDATE produk SET stok = stok - 1 WHERE id = 2;
COMMIT;
START TRANSACTION;
UPDATE pesanan SET status = 'dibatalkan' WHERE no_pesanan = 2 AND status = 'baru';
UPDATE produk SET stok = stok + 1 WHERE id = 2;
COMMIT;
-- Ekspektasi: pesanan 2 dibatalkan, TA-002 stok pulih ke 15
SELECT no_pesanan, status FROM pesanan WHERE no_pesanan = 2;
SELECT sku, stok FROM produk WHERE id = 2;

-- Pemeriksaan 5: pesanan ketiga (Budi) selesai di bulan Februari.
START TRANSACTION;
INSERT INTO pesanan (pelanggan_id, tanggal, status)
  VALUES (1, '2026-02-05', 'baru');
INSERT INTO detail_pesanan (pesanan_id, produk_id, jumlah, harga_satuan)
  VALUES (3, 1, 1, 45000.00);
UPDATE produk SET stok = stok - 1 WHERE id = 1;
COMMIT;
UPDATE pesanan SET status = 'selesai' WHERE no_pesanan = 3 AND status = 'baru';
-- Ekspektasi: pesanan 3 selesai
SELECT no_pesanan, status FROM pesanan WHERE no_pesanan = 3;

-- Pemeriksaan 6: omzet per bulan hanya dari pesanan yang menghasilkan uang.
-- Ekspektasi: 2026-01 omzet 118000.00 (pesanan 2 dibatalkan dikecualikan),
--   2026-02 omzet 45000.00
SELECT DATE_FORMAT(p.tanggal, '%Y-%m') AS bulan,
       SUM(d.jumlah * d.harga_satuan) AS omzet
FROM pesanan p
JOIN detail_pesanan d ON d.pesanan_id = p.no_pesanan
WHERE p.status IN ('dibayar','dikirim','selesai')
GROUP BY DATE_FORMAT(p.tanggal, '%Y-%m')
ORDER BY bulan;

-- Pemeriksaan 7: produk terlaris dengan RANK(); pesanan dibatalkan dikecualikan.
-- Ekspektasi: TS-001 3 unit peringkat 1, AK-001 1 unit peringkat 2,
--   TA-002 0 unit peringkat 3
SELECT p.sku,
       COALESCE(SUM(dj.jumlah), 0) AS total_terjual,
       RANK() OVER (ORDER BY COALESCE(SUM(dj.jumlah), 0) DESC) AS peringkat
FROM produk p
LEFT JOIN (
  SELECT d.produk_id, d.jumlah
  FROM detail_pesanan d
  JOIN pesanan pe ON pe.no_pesanan = d.pesanan_id
  WHERE pe.status <> 'dibatalkan'
) dj ON dj.produk_id = p.id
GROUP BY p.id, p.sku
ORDER BY total_terjual DESC, p.sku;

-- Pemeriksaan 8: distribusi status seluruh pesanan.
-- Ekspektasi: dibatalkan 1, dibayar 1, selesai 1
SELECT status, COUNT(*) AS jumlah_pesanan
FROM pesanan
GROUP BY status
ORDER BY status;

DROP DATABASE penjualan_db;