Agregasi bagian sebelumnya menjawab “berapa”; window function menjawab “bagaimana posisinya”. Pada bagian ini tabel penjualan_bersih dibaca ulang dengan tiga pertanyaan baru: produk mana yang juara di tiap cabang, berapa omzet yang sudah terkumpul sampai bulan tertentu, dan bagaimana tren mulus bila lonjakan sesaat dihaluskan. Semua query memakai pola yang sama — CTE merangkum data lebih dulu, window function membacanya — dan CTE itu akan kita rantai (beruntun) satu sama lain, pola khas SQL analitik yang menjaga query besar tetap terbaca.

Membangun Ulang Kondisi Awal #

Blok pembuka standar: skema, 28 baris mentah, lalu staging dari Bagian 2 dalam satu transaksi — hasilnya penjualan_bersih berisi 24 baris siap analisis:

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

CREATE TABLE penjualan_mentah (
  id INT AUTO_INCREMENT PRIMARY KEY,
  tanggal DATE NOT NULL,
  cabang VARCHAR(20) NOT NULL,
  produk VARCHAR(30) NOT NULL,
  qty INT NOT NULL,
  harga DECIMAL(10, 2) NOT NULL
);

CREATE TABLE penjualan_bersih (
  id INT AUTO_INCREMENT PRIMARY KEY,
  tanggal DATE NOT NULL,
  cabang VARCHAR(20) NOT NULL,
  produk VARCHAR(30) NOT NULL,
  qty INT NOT NULL,
  harga DECIMAL(10, 2) NOT NULL,
  CONSTRAINT uq_bersih_transaksi
    UNIQUE (tanggal, cabang, produk, qty, harga)
);

INSERT INTO penjualan_mentah (id, tanggal, cabang, produk, qty, harga) VALUES
  (1,  '2025-01-10', 'Jakarta',  'Kopi Gayo',      4, 55000.00),
  (2,  '2025-01-22', 'Bandung',  'Teh Melati',     6, 25000.00),
  (3,  '2025-02-08', 'Surabaya', 'Kopi Toraja',    2, 62000.00),
  (4,  '2025-02-19', 'Jakarta',  'Teh Hijau',      5, 28000.00),
  (5,  '2025-03-05', 'Bandung',  'Kopi Gayo',      2, 55000.00),
  (6,  '2025-03-17', 'Surabaya', 'Teh Melati',     4, 25000.00),
  (7,  '2025-04-09', 'Jakarta',  'Kopi Toraja',    2, 62000.00),
  (8,  '2025-04-21', 'Bandung',  'Grinder Manual', 1, 350000.00),
  (9,  '2025-05-06', 'Surabaya', 'Kopi Gayo',      2, 55000.00),
  (10, '2025-05-18', 'Jakarta',  'Teh Melati',     6, 25000.00),
  (11, '2025-06-03', 'Bandung',  'Kopi Toraja',    2, 62000.00),
  (12, '2025-06-15', 'Surabaya', 'Teh Hijau',      5, 28000.00),
  (13, '2026-01-09', 'Jakarta',  'Kopi Gayo',      3, 55000.00),
  (14, '2026-01-21', 'Bandung',  'Teh Melati',     7, 25000.00),
  (15, '2026-02-05', 'Surabaya', 'Kopi Toraja',    2, 62000.00),
  (16, '2026-02-17', 'Jakarta',  'Teh Hijau',      6, 28000.00),
  (17, '2026-03-04', 'Bandung',  'Kopi Gayo',      2, 55000.00),
  (18, '2026-03-16', 'Surabaya', 'Teh Melati',     3, 25000.00),
  (19, '2026-04-08', 'Jakarta',  'Kopi Toraja',    3, 62000.00),
  (20, '2026-04-20', 'Bandung',  'Grinder Manual', 2, 350000.00),
  (21, '2026-05-05', 'Surabaya', 'Kopi Gayo',      2, 55000.00),
  (22, '2026-05-17', 'Jakarta',  'Teh Melati',     7, 25000.00),
  (23, '2026-06-02', 'Bandung ', 'Kopi Toraja',    2, 62000.00),
  (24, '2026-06-14', 'Surabaya', 'Teh Hijau',      4, 28000.00),
  (25, '2025-01-22', 'Bandung',  'Teh Melati',     6, 25000.00),
  (26, '2026-01-09', 'Jakarta',  'Kopi Gayo',      3, 55000.00),
  (27, '2025-02-08', 'Surabaya', 'Kopi Toraja',    2, 62000.00),
  (28, '2026-03-30', 'Jakarta',  'Teh Hijau',      0, 28000.00);

START TRANSACTION;

INSERT INTO penjualan_bersih (tanggal, cabang, produk, qty, harga)
SELECT DISTINCT
  tanggal,
  TRIM(cabang) AS cabang,
  produk,
  qty,
  harga
FROM penjualan_mentah
WHERE qty > 0;

COMMIT;

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, 28 rows affected (0.00 sec)
Records: 28  Duplicates: 0  Warnings: 0

Query OK, 0 rows affected (0.00 sec)

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

Query OK, 0 rows affected (0.00 sec)

Peringkat Produk per Cabang #

Pertanyaan pertama meminta juara lokal: setiap cabang punya produk andalan sendiri. Kita rangkum omzet per pasangan cabang-produk lebih dulu, lalu RANK() memberi nomor di dalam masing-masing partisi cabang:

sql
WITH omzet_cabang_produk AS (
  SELECT
    cabang,
    produk,
    SUM(qty * harga) AS omzet
  FROM penjualan_bersih
  GROUP BY cabang, produk
)
SELECT
  cabang,
  produk,
  omzet,
  RANK() OVER (
    PARTITION BY cabang
    ORDER BY omzet DESC
  ) AS peringkat
FROM omzet_cabang_produk
ORDER BY cabang, peringkat;

Output yang Dihasilkan:

text
+----------+----------------+------------+-----------+
| cabang   | produk         | omzet      | peringkat |
+----------+----------------+------------+-----------+
| Bandung  | Grinder Manual | 1050000.00 |         1 |
| Bandung  | Teh Melati     |  325000.00 |         2 |
| Bandung  | Kopi Toraja    |  248000.00 |         3 |
| Bandung  | Kopi Gayo      |  220000.00 |         4 |
| Jakarta  | Kopi Gayo      |  385000.00 |         1 |
| Jakarta  | Teh Melati     |  325000.00 |         2 |
| Jakarta  | Kopi Toraja    |  310000.00 |         3 |
| Jakarta  | Teh Hijau      |  308000.00 |         4 |
| Surabaya | Teh Hijau      |  252000.00 |         1 |
| Surabaya | Kopi Toraja    |  248000.00 |         2 |
| Surabaya | Kopi Gayo      |  220000.00 |         3 |
| Surabaya | Teh Melati     |  175000.00 |         4 |
+----------+----------------+------------+-----------+
12 rows in set (0.00 sec)

Bedah elemen-elemennya:

  1. CTE omzet_cabang_produk mengerjakan pekerjaan rumah GROUP BY lebih dulu. Window function tidak menggantikan agregasi — ia membaca hasil agregasi, jadi pengurutan dua langkah ini adalah pola baku.
  2. PARTITION BY cabang membagi baris menjadi tiga jendela terpisah, satu per cabang. RANK() menghitung dari 1 di tiap jendela, sehingga Grinder Manual menjadi peringkat 1 di Bandung tanpa bersaing dengan Kopi Gayo yang juara di Jakarta.
  3. ORDER BY omzet DESC di dalam OVER menentukan dasar peringkat — bukan ORDER BY di akhir query, yang hanya mengatur tampilan.
  4. RANK() meninggalkan celah nomor saat ada seri (1, 1, 3); DENSE_RANK() merapatkannya (1, 1, 2). Pada data ini tiap cabang tidak memiliki produk yang seri omzetnya, tetapi di tingkat global seri itu nyata — kita lihat sebentar lagi.

Query ini adalah isi berkas queries/peringkat-produk.sql. Varian berikutnya menyalahgunakan kekuatan rantai CTE: dua CTE beruntun yang kedua menyaring hasil window function pertama — pola “top-N per kelompok” yang tidak bisa ditulis dengan WHERE biasa:

sql
WITH omzet_cabang_produk AS (
  SELECT
    cabang,
    produk,
    SUM(qty * harga) AS omzet
  FROM penjualan_bersih
  GROUP BY cabang, produk
),
berperingkat AS (
  SELECT
    cabang,
    produk,
    omzet,
    RANK() OVER (
      PARTITION BY cabang
      ORDER BY omzet DESC
    ) AS peringkat
  FROM omzet_cabang_produk
)
SELECT cabang, produk, omzet
FROM berperingkat
WHERE peringkat <= 2
ORDER BY cabang, peringkat;

Output yang Dihasilkan:

text
+----------+----------------+------------+
| cabang   | produk         | omzet      |
+----------+----------------+------------+
| Bandung  | Grinder Manual | 1050000.00 |
| Bandung  | Teh Melati     |  325000.00 |
| Jakarta  | Kopi Gayo      |  385000.00 |
| Jakarta  | Teh Melati     |  325000.00 |
| Surabaya | Teh Hijau      |  252000.00 |
| Surabaya | Kopi Toraja    |  248000.00 |
+----------+----------------+------------+
6 rows in set (0.00 sec)

Filter WHERE peringkat <= 2 berada pada CTE kedua, bukan pada query pertama. Alasannya murni urutan evaluasi: klausa WHERE dieksekusi sebelum fungsi window dihitung, sehingga menyaring alias jendela langsung di query pertama akan ditolak — peringkat belum ada saat WHERE bekerja. Dengan membungkusnya sebagai CTE, hasil RANK() menjadi kolom biasa yang boleh disaring seperti kolom mana pun.

Sebagai penutup tema peringkat, kita adukan tiga fungsi penomoran secara berdampingan pada peringkat global produk. Kopi Gayo dan Teh Melati sama-sama beromzet 825000.00 — pasangan seri yang membuat ketiga fungsi berbeda perilaku:

sql
WITH omzet_produk AS (
  SELECT
    produk,
    SUM(qty * harga) AS omzet
  FROM penjualan_bersih
  GROUP BY produk
)
SELECT
  produk,
  omzet,
  ROW_NUMBER() OVER (ORDER BY omzet DESC, produk) AS nomor_urut,
  RANK()       OVER (ORDER BY omzet DESC)         AS peringkat,
  DENSE_RANK() OVER (ORDER BY omzet DESC)         AS peringkat_padat
FROM omzet_produk
ORDER BY nomor_urut;

Output yang Dihasilkan:

text
+----------------+------------+------------+-----------+-----------------+
| produk         | omzet      | nomor_urut | peringkat | peringkat_padat |
+----------------+------------+------------+-----------+-----------------+
| Grinder Manual | 1050000.00 |          1 |         1 |               1 |
| Kopi Gayo      |  825000.00 |          2 |         2 |               2 |
| Teh Melati     |  825000.00 |          3 |         2 |               2 |
| Kopi Toraja    |  806000.00 |          4 |         4 |               3 |
| Teh Hijau      |  560000.00 |          5 |         5 |               4 |
+----------------+------------+------------+-----------+-----------------+
5 rows in set (0.00 sec)

ROW_NUMBER() memaksa setiap baris unik lewat tiebreaker produk; RANK() memberi kedua produk seri nomor 2 lalu melompat ke 4; DENSE_RANK() memberi nomor 2 dan lanjut ke 3. Pilih fungsi sesuai makna laporan: nomor undian butuh ROW_NUMBER, papan peringkat juara butuh RANK, klasemen tanpa celah butuh DENSE_RANK.

Running Total Omzet per Bulan #

Pertanyaan kedua: berapa omzet yang sudah terkumpul sampai tiap bulan? Ini wilayah SUM() OVER dengan frame berjalan:

sql
WITH omzet_bulanan AS (
  SELECT
    DATE_FORMAT(tanggal, '%Y-%m') AS bulan,
    SUM(qty * harga) AS omzet
  FROM penjualan_bersih
  GROUP BY bulan
)
SELECT
  bulan,
  omzet,
  SUM(omzet) OVER (
    ORDER BY bulan
    ROWS UNBOUNDED PRECEDING
  ) AS omzet_berjalan
FROM omzet_bulanan
ORDER BY bulan;

Output yang Dihasilkan:

text
+---------+-----------+----------------+
| bulan   | omzet     | omzet_berjalan |
+---------+-----------+----------------+
| 2025-01 | 370000.00 |      370000.00 |
| 2025-02 | 264000.00 |      634000.00 |
| 2025-03 | 210000.00 |      844000.00 |
| 2025-04 | 474000.00 |     1318000.00 |
| 2025-05 | 260000.00 |     1578000.00 |
| 2025-06 | 264000.00 |     1842000.00 |
| 2026-01 | 340000.00 |     2182000.00 |
| 2026-02 | 292000.00 |     2474000.00 |
| 2026-03 | 185000.00 |     2659000.00 |
| 2026-04 | 886000.00 |     3545000.00 |
| 2026-05 | 285000.00 |     3830000.00 |
| 2026-06 | 236000.00 |     4066000.00 |
+---------+-----------+----------------+
12 rows in set (0.00 sec)

Tiga hal bekerja bersama di sini:

  1. ORDER BY bulan di dalam OVER mengurutkan jendela menurut string YYYY-MM — format yang kebetulan terurut kronologis sebagai teks, sehingga tidak perlu konversi tanggal lagi.
  2. ROWS UNBOUNDED PRECEDING menetapkan frame dari baris pertama sampai baris sekarang. Baris Maret menjumlahkan Januari+Februari+Maret — itulah arti “berjalan”.
  3. Tanpa PARTITION BY semua 12 bulan berada dalam satu jendela global, tepat yang dibutuhkan total kumulatif lintas tahun. Baris terakhir menunjukkan 4066000.00 — total seluruh omzet, cocok dengan penjumlahan tiga cabang dari Bagian 2.

Frame ROWS ditulis eksplisit karena frame default MySQL adalah RANGE, yang menghitung per nilai urutan yang sama, bukan per baris. Pada data bulanan unik keduanya berimbang, namun ROWS menyatakan maksud tanpa bergantung pada keunikan data.

Moving Average Tiga Bulan #

Pertanyaan ketiga menghaluskan deret: rata-rata omzet tiga bulan terakhir meredam lonjakan sesaat. Fungsi AVG() OVER dengan frame bergerak menggantikan SUM:

sql
WITH omzet_bulanan AS (
  SELECT
    DATE_FORMAT(tanggal, '%Y-%m') AS bulan,
    SUM(qty * harga) AS omzet
  FROM penjualan_bersih
  GROUP BY bulan
)
SELECT
  bulan,
  omzet,
  ROUND(
    AVG(omzet) OVER (
      ORDER BY bulan
      ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
    ), 2
  ) AS rata3_bulan
FROM omzet_bulanan
ORDER BY bulan;

Output yang Dihasilkan:

text
+---------+-----------+-------------+
| bulan   | omzet     | rata3_bulan |
+---------+-----------+-------------+
| 2025-01 | 370000.00 |   370000.00 |
| 2025-02 | 264000.00 |   317000.00 |
| 2025-03 | 210000.00 |   281333.33 |
| 2025-04 | 474000.00 |   316000.00 |
| 2025-05 | 260000.00 |   314666.67 |
| 2025-06 | 264000.00 |   332666.67 |
| 2026-01 | 340000.00 |   288000.00 |
| 2026-02 | 292000.00 |   298666.67 |
| 2026-03 | 185000.00 |   272333.33 |
| 2026-04 | 886000.00 |   454333.33 |
| 2026-05 | 285000.00 |   452000.00 |
| 2026-06 | 236000.00 |   469000.00 |
+---------+-----------+-------------+
12 rows in set (0.00 sec)

Frame ROWS BETWEEN 2 PRECEDING AND CURRENT ROW mengambil dua baris sebelumnya plus baris sekarang — tepat tiga baris, kecuali di awal deret: Januari 2025 hanya berframe satu baris dan Februari dua, sehingga rata-ratanya “mendesak” dari bawah. Efek tepi ini normal dan patut dijelaskan ke pembaca laporan. Perhatikan pula bulan 2026-04: omzet asli melonjak ke 886000.00 karena pembelian Grinder Manual di Bandung, namun rata3_bulan hanya naik ke 454333.33 — itulah pekerjaan smoothing.

Laporan Tren: Dua CTE Beruntun #

Sebagai penutup bagian, kedua penghitungan dirangkai menjadi satu laporan tren — CTE pertama merangkum, CTE kedua menghitung dua window sekaligus:

sql
WITH omzet_bulanan AS (
  SELECT
    DATE_FORMAT(tanggal, '%Y-%m') AS bulan,
    SUM(qty * harga) AS omzet
  FROM penjualan_bersih
  GROUP BY bulan
),
tren AS (
  SELECT
    bulan,
    omzet,
    SUM(omzet) OVER (
      ORDER BY bulan
      ROWS UNBOUNDED PRECEDING
    ) AS omzet_berjalan,
    AVG(omzet) OVER (
      ORDER BY bulan
      ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
    ) AS rata3
  FROM omzet_bulanan
)
SELECT
  bulan,
  omzet,
  omzet_berjalan,
  ROUND(rata3, 2) AS rata3_bulan
FROM tren
ORDER BY bulan;

Output yang Dihasilkan:

text
+---------+-----------+----------------+-------------+
| bulan   | omzet     | omzet_berjalan | rata3_bulan |
+---------+-----------+----------------+-------------+
| 2025-01 | 370000.00 |      370000.00 |   370000.00 |
| 2025-02 | 264000.00 |      634000.00 |   317000.00 |
| 2025-03 | 210000.00 |      844000.00 |   281333.33 |
| 2025-04 | 474000.00 |     1318000.00 |   316000.00 |
| 2025-05 | 260000.00 |     1578000.00 |   314666.67 |
| 2025-06 | 264000.00 |     1842000.00 |   332666.67 |
| 2026-01 | 340000.00 |     2182000.00 |   288000.00 |
| 2026-02 | 292000.00 |     2474000.00 |   298666.67 |
| 2026-03 | 185000.00 |     2659000.00 |   272333.33 |
| 2026-04 | 886000.00 |     3545000.00 |   454333.33 |
| 2026-05 | 285000.00 |     3830000.00 |   452000.00 |
| 2026-06 | 236000.00 |     4066000.00 |   469000.00 |
+---------+-----------+----------------+-------------+
12 rows in set (0.00 sec)

Rantai dua CTE menjaga tiap langkah terbaca: omzet_bulanan merangkum data mentah bersih, tren menghitung jendela, dan SELECT akhir hanya memformat pembulatan. Query analitik sungguhan kerap berantai lima-enam CTE dengan struktur persis seperti ini — kebiasaan menamai setiap langkah akan banyak membantu. Dua berkas terpisah queries/running-total.sql dan queries/moving-average.sql menyimpan versi satu-fungsi dari laporan ini:

bash
mysql -u root -p analitik_db < queries/running-total.sql
mysql -u root -p analitik_db < queries/moving-average.sql

Membersihkan Database Latihan #

Bagian ini selesai dan analitik_db boleh kita bongkar:

sql
DROP DATABASE IF EXISTS analitik_db;

Output yang Dihasilkan:

text
Query OK, 2 rows affected (0.00 sec)

Referensi #

Peringkat, total berjalan, dan tren mulus sudah tertulis dari data yang sama. Bagian 4 menyusun laporan akhir manajer: perbandingan tahun ke tahun lewat self-join, kontribusi kumulatif produk ala Pareto, dan ringkasan top-3 plus “Lainnya” — lalu mengunci semuanya dengan uji otomatis.

Berkas Sumber

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

database/data-awal.sql

Unduh berkas
sql
-- =============================================================
-- Nama berkas : data-awal.sql
-- Proyek      : Database Analisis Penjualan (Course MySQL)
-- Tujuan      : Memuat 28 baris data penjualan mentah Januari-Juni
--               dua tahun (2025 dan 2026) — sengaja berisi cacat:
--               3 baris duplikat persis (id 25-27), 1 baris qty nol
--               (id 28), dan 1 cabang dengan spasi buntut 'Bandung '
--               (id 23). Perlakukan seolah ekspor dari sistem luar.
-- Cara pakai  : mysql -u root -p analitik_db < database/data-awal.sql
--               (jalankan setelah database/skema.sql)
-- =============================================================

USE analitik_db;

INSERT INTO penjualan_mentah (id, tanggal, cabang, produk, qty, harga) VALUES
  (1,  '2025-01-10', 'Jakarta',  'Kopi Gayo',      4, 55000.00),
  (2,  '2025-01-22', 'Bandung',  'Teh Melati',     6, 25000.00),
  (3,  '2025-02-08', 'Surabaya', 'Kopi Toraja',    2, 62000.00),
  (4,  '2025-02-19', 'Jakarta',  'Teh Hijau',      5, 28000.00),
  (5,  '2025-03-05', 'Bandung',  'Kopi Gayo',      2, 55000.00),
  (6,  '2025-03-17', 'Surabaya', 'Teh Melati',     4, 25000.00),
  (7,  '2025-04-09', 'Jakarta',  'Kopi Toraja',    2, 62000.00),
  (8,  '2025-04-21', 'Bandung',  'Grinder Manual', 1, 350000.00),
  (9,  '2025-05-06', 'Surabaya', 'Kopi Gayo',      2, 55000.00),
  (10, '2025-05-18', 'Jakarta',  'Teh Melati',     6, 25000.00),
  (11, '2025-06-03', 'Bandung',  'Kopi Toraja',    2, 62000.00),
  (12, '2025-06-15', 'Surabaya', 'Teh Hijau',      5, 28000.00),
  (13, '2026-01-09', 'Jakarta',  'Kopi Gayo',      3, 55000.00),
  (14, '2026-01-21', 'Bandung',  'Teh Melati',     7, 25000.00),
  (15, '2026-02-05', 'Surabaya', 'Kopi Toraja',    2, 62000.00),
  (16, '2026-02-17', 'Jakarta',  'Teh Hijau',      6, 28000.00),
  (17, '2026-03-04', 'Bandung',  'Kopi Gayo',      2, 55000.00),
  (18, '2026-03-16', 'Surabaya', 'Teh Melati',     3, 25000.00),
  (19, '2026-04-08', 'Jakarta',  'Kopi Toraja',    3, 62000.00),
  (20, '2026-04-20', 'Bandung',  'Grinder Manual', 2, 350000.00),
  (21, '2026-05-05', 'Surabaya', 'Kopi Gayo',      2, 55000.00),
  (22, '2026-05-17', 'Jakarta',  'Teh Melati',     7, 25000.00),
  (23, '2026-06-02', 'Bandung ', 'Kopi Toraja',    2, 62000.00),
  (24, '2026-06-14', 'Surabaya', 'Teh Hijau',      4, 28000.00),
  (25, '2025-01-22', 'Bandung',  'Teh Melati',     6, 25000.00),
  (26, '2026-01-09', 'Jakarta',  'Kopi Gayo',      3, 55000.00),
  (27, '2025-02-08', 'Surabaya', 'Kopi Toraja',    2, 62000.00),
  (28, '2026-03-30', 'Jakarta',  'Teh Hijau',      0, 28000.00);

database/skema.sql

Unduh berkas
sql
-- =============================================================
-- Nama berkas : skema.sql
-- Proyek      : Database Analisis Penjualan (Course MySQL)
-- Tujuan      : Membangun ulang database analitik_db dengan dua
--               tabel — penjualan_mentah (staging data kotor,
--               tanpa constraint kualitas) dan penjualan_bersih
--               (hasil pembersihan, dijaga UNIQUE komposit lima
--               kolom agar baris duplikat tidak bisa masuk).
-- Cara pakai  : mysql -u root -p < database/skema.sql
--               (lanjutkan dengan database/data-awal.sql dan
--               queries/staging-bersih.sql)
-- =============================================================

DROP DATABASE IF EXISTS analitik_db;
CREATE DATABASE analitik_db;
USE analitik_db;

CREATE TABLE penjualan_mentah (
  id INT AUTO_INCREMENT PRIMARY KEY,
  tanggal DATE NOT NULL,
  cabang VARCHAR(20) NOT NULL,
  produk VARCHAR(30) NOT NULL,
  qty INT NOT NULL,
  harga DECIMAL(10, 2) NOT NULL
);

CREATE TABLE penjualan_bersih (
  id INT AUTO_INCREMENT PRIMARY KEY,
  tanggal DATE NOT NULL,
  cabang VARCHAR(20) NOT NULL,
  produk VARCHAR(30) NOT NULL,
  qty INT NOT NULL,
  harga DECIMAL(10, 2) NOT NULL,
  CONSTRAINT uq_bersih_transaksi
    UNIQUE (tanggal, cabang, produk, qty, harga)
);

queries/kontribusi-kumulatif.sql

Unduh berkas
sql
-- =============================================================
-- Nama berkas : kontribusi-kumulatif.sql
-- Proyek      : Database Analisis Penjualan (Course MySQL)
-- Tujuan      : Menghitung kontribusi omzet tiap produk terhadap
--               total beserta akumulatifnya (pola Pareto) memakai
--               dua window function berjenjang.
-- Cara pakai  : mysql -u root -p analitik_db < queries/kontribusi-kumulatif.sql
-- =============================================================

USE analitik_db;

WITH per_produk AS (
  SELECT
    produk,
    SUM(qty * harga) AS omzet
  FROM penjualan_bersih
  GROUP BY produk
)
SELECT
  produk,
  omzet,
  ROUND(omzet / SUM(omzet) OVER () * 100, 1) AS kontribusi_persen,
  ROUND(
    SUM(omzet) OVER (
      ORDER BY omzet DESC, produk
      ROWS UNBOUNDED PRECEDING
    ) / SUM(omzet) OVER () * 100, 1
  ) AS kumulatif_persen
FROM per_produk
ORDER BY omzet DESC, produk;

queries/laporan-yoy.sql

Unduh berkas
sql
-- =============================================================
-- Nama berkas : laporan-yoy.sql
-- Proyek      : Database Analisis Penjualan (Course MySQL)
-- Tujuan      : Membandingkan omzet tiap cabang Januari-Juni 2026
--               terhadap periode sama tahun 2025 (YoY) lewat CTE
--               yang di-self-join.
-- Cara pakai  : mysql -u root -p analitik_db < queries/laporan-yoy.sql
-- =============================================================

USE analitik_db;

WITH omzet_cabang AS (
  SELECT
    YEAR(tanggal) AS tahun,
    cabang,
    SUM(qty * harga) AS omzet
  FROM penjualan_bersih
  GROUP BY tahun, cabang
)
SELECT
  a.cabang,
  a.omzet AS omzet_2025,
  b.omzet AS omzet_2026,
  ROUND((b.omzet - a.omzet) / a.omzet * 100, 1) AS perubahan_persen
FROM omzet_cabang a
JOIN omzet_cabang b
  ON b.cabang = a.cabang AND b.tahun = 2026
WHERE a.tahun = 2025
ORDER BY perubahan_persen DESC, a.cabang;

queries/moving-average.sql

Unduh berkas
sql
-- =============================================================
-- Nama berkas : moving-average.sql
-- Proyek      : Database Analisis Penjualan (Course MySQL)
-- Tujuan      : Menghitung rata-rata bergerak (moving average) omzet
--               3 bulan dengan AVG() OVER dan frame ROWS.
-- Cara pakai  : mysql -u root -p analitik_db < queries/moving-average.sql
-- =============================================================

USE analitik_db;

WITH omzet_bulanan AS (
  SELECT
    DATE_FORMAT(tanggal, '%Y-%m') AS bulan,
    SUM(qty * harga) AS omzet
  FROM penjualan_bersih
  GROUP BY bulan
)
SELECT
  bulan,
  omzet,
  ROUND(
    AVG(omzet) OVER (
      ORDER BY bulan
      ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
    ), 2
  ) AS rata3_bulan
FROM omzet_bulanan
ORDER BY bulan;

queries/omzet-bulanan.sql

Unduh berkas
sql
-- =============================================================
-- Nama berkas : omzet-bulanan.sql
-- Proyek      : Database Analisis Penjualan (Course MySQL)
-- Tujuan      : Merangkum omzet per bulan (YYYY-MM) dari data
--               bersih, terurut naik.
-- Cara pakai  : mysql -u root -p analitik_db < queries/omzet-bulanan.sql
-- =============================================================

USE analitik_db;

SELECT
  DATE_FORMAT(tanggal, '%Y-%m') AS bulan,
  COUNT(*) AS jumlah_transaksi,
  SUM(qty * harga) AS omzet
FROM penjualan_bersih
GROUP BY bulan
ORDER BY bulan;

queries/omzet-per-cabang.sql

Unduh berkas
sql
-- =============================================================
-- Nama berkas : omzet-per-cabang.sql
-- Proyek      : Database Analisis Penjualan (Course MySQL)
-- Tujuan      : Menghitung total omzet (jumlah x harga) tiap cabang
--               dari data bersih, lengkap jumlah transaksinya.
-- Cara pakai  : mysql -u root -p analitik_db < queries/omzet-per-cabang.sql
-- =============================================================

USE analitik_db;

SELECT
  cabang,
  COUNT(*) AS jumlah_transaksi,
  SUM(qty * harga) AS omzet
FROM penjualan_bersih
GROUP BY cabang
ORDER BY omzet DESC, cabang;

queries/omzet-per-produk.sql

Unduh berkas
sql
-- =============================================================
-- Nama berkas : omzet-per-produk.sql
-- Proyek      : Database Analisis Penjualan (Course MySQL)
-- Tujuan      : Menghitung omzet dan total unit tiap produk dari
--               data bersih, terurut dari omzet terbesar.
-- Cara pakai  : mysql -u root -p analitik_db < queries/omzet-per-produk.sql
-- =============================================================

USE analitik_db;

SELECT
  produk,
  SUM(qty) AS total_unit,
  SUM(qty * harga) AS omzet
FROM penjualan_bersih
GROUP BY produk
ORDER BY omzet DESC, produk;

queries/peringkat-produk.sql

Unduh berkas
sql
-- =============================================================
-- Nama berkas : peringkat-produk.sql
-- Proyek      : Database Analisis Penjualan (Course MySQL)
-- Tujuan      : Memberi peringkat produk berdasarkan omzet di dalam
--               masing-masing cabang dengan CTE agregat + RANK()
--               OVER (PARTITION BY cabang ORDER BY omzet DESC).
-- Cara pakai  : mysql -u root -p analitik_db < queries/peringkat-produk.sql
--               (jalankan setelah staging; tambahkan filter
--               peringkat <= 2 untuk dua produk teratas per cabang)
-- =============================================================

USE analitik_db;

WITH omzet_cabang_produk AS (
  SELECT
    cabang,
    produk,
    SUM(qty * harga) AS omzet
  FROM penjualan_bersih
  GROUP BY cabang, produk
)
SELECT
  cabang,
  produk,
  omzet,
  RANK() OVER (
    PARTITION BY cabang
    ORDER BY omzet DESC
  ) AS peringkat
FROM omzet_cabang_produk
ORDER BY cabang, peringkat;

queries/running-total.sql

Unduh berkas
sql
-- =============================================================
-- Nama berkas : running-total.sql
-- Proyek      : Database Analisis Penjualan (Course MySQL)
-- Tujuan      : Menghitung omzet berjalan (running total) per bulan
--               dengan CTE agregat + SUM() OVER ber-frame ROWS
--               UNBOUNDED PRECEDING.
-- Cara pakai  : mysql -u root -p analitik_db < queries/running-total.sql
--               (jalankan setelah staging)
-- =============================================================

USE analitik_db;

WITH omzet_bulanan AS (
  SELECT
    DATE_FORMAT(tanggal, '%Y-%m') AS bulan,
    SUM(qty * harga) AS omzet
  FROM penjualan_bersih
  GROUP BY bulan
)
SELECT
  bulan,
  omzet,
  SUM(omzet) OVER (
    ORDER BY bulan
    ROWS UNBOUNDED PRECEDING
  ) AS omzet_berjalan
FROM omzet_bulanan
ORDER BY bulan;

queries/staging-bersih.sql

Unduh berkas
sql
-- =============================================================
-- Nama berkas : staging-bersih.sql
-- Proyek      : Database Analisis Penjualan (Course MySQL)
-- Tujuan      : Proses staging — menyalin data dari penjualan_mentah
--               ke penjualan_bersih sekaligus membersihkannya dalam
--               satu transaksi: buang qty nol (WHERE qty > 0),
--               buang duplikat persis (SELECT DISTINCT), dan rapikan
--               spasi buntut cabang lewat TRIM.
-- Cara pakai  : mysql -u root -p analitik_db < queries/staging-bersih.sql
--               (jalankan setelah database/skema.sql dan
--               database/data-awal.sql)
-- =============================================================

USE analitik_db;

START TRANSACTION;

INSERT INTO penjualan_bersih (tanggal, cabang, produk, qty, harga)
SELECT DISTINCT
  tanggal,
  TRIM(cabang) AS cabang,
  produk,
  qty,
  harga
FROM penjualan_mentah
WHERE qty > 0;

COMMIT;

queries/top-n-lainnya.sql

Unduh berkas
sql
-- =============================================================
-- Nama berkas : top-n-lainnya.sql
-- Proyek      : Database Analisis Penjualan (Course MySQL)
-- Tujuan      : Menampilkan 3 produk beromzet terbesar apa adanya
--               lalu menggumpalkan sisanya menjadi baris Lainnya
--               lewat ROW_NUMBER + UNION ALL. Kolom posisi tetap
--               (nomor_urut 4) menjaga baris Lainnya di urutan
--               terakhir laporan.
-- Cara pakai  : mysql -u root -p analitik_db < queries/top-n-lainnya.sql
--               (jalankan setelah staging)
-- =============================================================

USE analitik_db;

WITH per_produk AS (
  SELECT
    produk,
    SUM(qty * harga) AS omzet
  FROM penjualan_bersih
  GROUP BY produk
),
berperingkat AS (
  SELECT
    produk,
    omzet,
    ROW_NUMBER() OVER (ORDER BY omzet DESC, produk) AS nomor_urut
  FROM per_produk
)
SELECT produk, omzet
FROM (
  SELECT nomor_urut, produk, omzet
  FROM berperingkat
  WHERE nomor_urut <= 3
  UNION ALL
  SELECT 4, 'Lainnya', SUM(omzet)
  FROM berperingkat
  WHERE nomor_urut > 3
) AS ringkas
ORDER BY nomor_urut;

tests/pemeriksaan.sql

Unduh berkas
sql
-- =============================================================
-- Nama berkas : pemeriksaan.sql
-- Proyek      : Database Analisis Penjualan (Course MySQL)
-- Tujuan      : Uji akhir otomatis — memeriksa hasil staging
--               (baris mentah vs bersih, cabang rapi), omzet per
--               tahun, produk teratas, dan kelengkapan bulan.
--               Ekspektasi tiap query dituliskan sebagai komentar.
-- Cara pakai  : mysql -h 127.0.0.1 -P 3306 -u root < tests/pemeriksaan.sql
--               (jalankan setelah database/skema.sql, data-awal.sql,
--               dan queries/staging-bersih.sql)
-- =============================================================

USE analitik_db;

-- Pemeriksaan 1: data mentah termuat lengkap.
-- Ekspektasi: baris_mentah = 28.
SELECT COUNT(*) AS baris_mentah FROM penjualan_mentah;

-- Pemeriksaan 2: staging membuang 3 duplikat dan 1 baris qty nol.
-- Ekspektasi: baris_bersih = 24.
SELECT COUNT(*) AS baris_bersih FROM penjualan_bersih;

-- Pemeriksaan 3: spasi buntut cabang hilang — hanya 3 cabang tersisa.
-- Ekspektasi: 3 baris — Bandung 8, Jakarta 8, Surabaya 8.
SELECT cabang, COUNT(*) AS baris
FROM penjualan_bersih
GROUP BY cabang
ORDER BY cabang;

-- Pemeriksaan 4: omzet per tahun (Januari-Juni 2025 dan 2026).
-- Ekspektasi: 2 baris — 2025 sebesar 1842000.00, 2026 sebesar 2224000.00.
SELECT
  YEAR(tanggal) AS tahun,
  SUM(qty * harga) AS omzet
FROM penjualan_bersih
GROUP BY tahun
ORDER BY tahun;

-- Pemeriksaan 5: produk beromzet terbesar.
-- Ekspektasi: 1 baris — Grinder Manual dengan omzet 1050000.00.
SELECT
  produk,
  SUM(qty * harga) AS omzet
FROM penjualan_bersih
GROUP BY produk
ORDER BY omzet DESC, produk
LIMIT 1;

-- Pemeriksaan 6: dua produk seri di peringkat kedua.
-- Ekspektasi: 2 baris — Kopi Gayo dan Teh Melati, keduanya 825000.00.
SELECT
  produk,
  SUM(qty * harga) AS omzet
FROM penjualan_bersih
GROUP BY produk
HAVING SUM(qty * harga) = 825000.00
ORDER BY produk;

-- Pemeriksaan 7: setiap bulan Januari-Juni pada kedua tahun berisi data.
-- Ekspektasi: jumlah_bulan = 12.
SELECT COUNT(*) AS jumlah_bulan
FROM (
  SELECT DATE_FORMAT(tanggal, '%Y-%m') AS bulan
  FROM penjualan_bersih
  GROUP BY bulan
) AS per_bulan;