Papan tugas yang sehat punya dua sumber kebenaran yang harus tidak mungkin berselisih: kolom status di tabel tugas yang dibaca aplikasi setiap detik, dan tabel riwayat_status yang dicatat setiap kali status berpindah. Bila aplikasi hanya memperbarui kolom status lalu gagal menulis riwayat, papan tugas tetap tampak baik-baik saja sementara jejak historisnya bolong — kecurangan yang baru ketahuan berminggu-minggu kemudian. Bagian ini menutup celah tersebut dengan transaksi MySQL: dua pernyataan yang bergerak bersama atau sama sekali tidak jalan. Kita membangun alur lengkap dari mengambil tugas, mengubah status dengan pencatatan riwayat, sampai membuktikan bahwa ROLLBACK mengembalikan segalanya ketika langkah kedua gagal.

Membangun Ulang Kondisi Awal #

Bagian ini mandiri, jadi kita bangun ulang tugas_db beserta seluruh datanya — skema dan data yang sama dengan Bagian 2:

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

Output yang Dihasilkan:

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

Query OK, 1 row affected (0.00 sec)

Database changed
sql
CREATE TABLE pengguna (
  id INT AUTO_INCREMENT PRIMARY KEY,
  nama VARCHAR(50) NOT NULL,
  email VARCHAR(60) NOT NULL UNIQUE
);

CREATE TABLE proyek (
  id INT AUTO_INCREMENT PRIMARY KEY,
  nama VARCHAR(60) NOT NULL,
  pemilik_id INT NOT NULL,
  CONSTRAINT fk_proyek_pemilik
    FOREIGN KEY (pemilik_id) REFERENCES pengguna (id)
);

CREATE TABLE tugas (
  id INT AUTO_INCREMENT PRIMARY KEY,
  judul VARCHAR(80) NOT NULL,
  proyek_id INT NOT NULL,
  assignee_id INT NULL,
  prioritas ENUM('rendah', 'sedang', 'tinggi') NOT NULL,
  tenggat DATE NULL,
  status ENUM('baru', 'berjalan', 'selesai') NOT NULL,
  CONSTRAINT fk_tugas_proyek
    FOREIGN KEY (proyek_id) REFERENCES proyek (id),
  CONSTRAINT fk_tugas_assignee
    FOREIGN KEY (assignee_id) REFERENCES pengguna (id)
);

CREATE TABLE riwayat_status (
  id INT AUTO_INCREMENT PRIMARY KEY,
  tugas_id INT NOT NULL,
  status_lama ENUM('baru', 'berjalan', 'selesai') NOT NULL,
  status_baru ENUM('baru', 'berjalan', 'selesai') NOT NULL,
  waktu DATETIME NOT NULL,
  catatan VARCHAR(200),
  CONSTRAINT fk_riwayat_tugas
    FOREIGN KEY (tugas_id) REFERENCES tugas (id)
);

INSERT INTO pengguna (id, nama, email) VALUES
  (1, 'Budi Santoso', '[email protected]'),
  (2, 'Siti Rahma', '[email protected]'),
  (3, 'Andi Wijaya', '[email protected]'),
  (4, 'Dewi Lestari', '[email protected]'),
  (5, 'Tono Pratama', '[email protected]'),
  (6, 'Rina Kusuma', '[email protected]');

INSERT INTO proyek (id, nama, pemilik_id) VALUES
  (1, 'Rilis Aplikasi Mobile', 1),
  (2, 'Migrasi Data Gudang', 2),
  (3, 'Kampanye Pemasaran Q1', 4);

INSERT INTO tugas (id, judul, proyek_id, assignee_id, prioritas, tenggat, status) VALUES
  (1,  'Desain mockup halaman utama',  1, 3,    'tinggi', '2026-02-10', 'selesai'),
  (2,  'Implementasi API autentikasi', 1, 1,    'tinggi', '2026-02-10', 'berjalan'),
  (3,  'Uji coba beta di Play Store',  1, NULL, 'sedang', '2026-03-15', 'baru'),
  (4,  'Pemetaan tabel lama ke baru',  2, 2,    'tinggi', '2026-02-05', 'selesai'),
  (5,  'Skrip migrasi batch pertama', 2, 5,    'sedang', '2026-02-25', 'berjalan'),
  (6,  'Validasi hasil migrasi',      2, 4,    'rendah', '2026-03-01', 'baru'),
  (7,  'Menyusun materi iklan',       3, 4,    'sedang', '2026-02-12', 'selesai'),
  (8,  'Riset kata kunci iklan',      3, 3,    'rendah', '2026-02-08', 'selesai'),
  (9,  'Negosiasi anggaran media',    3, 1,    'tinggi', '2026-02-12', 'berjalan'),
  (10, 'Laporan performa kampanye',   3, NULL, 'sedang', '2026-04-10', 'baru');

INSERT INTO riwayat_status (id, tugas_id, status_lama, status_baru, waktu, catatan) VALUES
  (1,  1, 'baru',     'berjalan', '2026-01-06 10:15:00', 'Mulai kerjakan mockup'),
  (2,  1, 'berjalan', 'selesai',  '2026-02-09 16:40:00', 'Mockup disetujui tim'),
  (3,  2, 'baru',     'berjalan', '2026-01-20 11:00:00', 'Sprint 1 dimulai'),
  (4,  4, 'baru',     'berjalan', '2026-01-07 09:00:00', 'Pemetaan awal berjalan'),
  (5,  4, 'berjalan', 'selesai',  '2026-02-04 17:00:00', 'Pemetaan tervalidasi'),
  (6,  5, 'baru',     'berjalan', '2026-01-15 10:30:00', 'Skrip versi awal mulai dikerjakan'),
  (7,  7, 'baru',     'berjalan', '2026-01-12 10:00:00', 'Brief iklan diterima'),
  (8,  7, 'berjalan', 'selesai',  '2026-02-11 15:30:00', 'Materi disetujui manajemen'),
  (9,  8, 'baru',     'berjalan', '2026-01-13 09:10:00', 'Riset kata kunci dimulai'),
  (10, 8, 'berjalan', 'selesai',  '2026-02-06 12:00:00', 'Riset selesai, 40 kata kunci'),
  (11, 9, 'baru',     'berjalan', '2026-01-25 10:30:00', 'Rapat awal dengan vendor');

Output yang Dihasilkan:

text
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)

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

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

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

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

Alur Mengubah Status dalam Satu Transaksi #

Skenario pertama: Rina mengambil tugas 3 — “Uji coba beta di Play Store” — yang selama ini belum dimiliki siapa pun. Mengambil tugas berarti dua perubahan sekaligus: assignee_id diisi dan status naik dari baru ke berjalan, dan setiap perubahan status wajib meninggalkan baris riwayat. Kedua aksi itu kita bungkus satu transaksi:

sql
START TRANSACTION;

UPDATE tugas
SET assignee_id = 6, status = 'berjalan'
WHERE id = 3;

INSERT INTO riwayat_status (tugas_id, status_lama, status_baru, waktu, catatan)
VALUES (3, 'baru', 'berjalan', '2026-02-01 09:00:00', 'Diambil Rina untuk sprint beta');

COMMIT;

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

Query OK, 1 row affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Setelah COMMIT, kedua tabel bergerak serentak. Mari kita buktikan hasil akhirnya dari kedua sisi:

sql
SELECT id, judul, assignee_id, status FROM tugas WHERE id = 3;

Output yang Dihasilkan:

text
+----+-----------------------------+-------------+----------+
| id | judul                       | assignee_id | status   |
+----+-----------------------------+-------------+----------+
|  3 | Uji coba beta di Play Store |           6 | berjalan |
+----+-----------------------------+-------------+----------+
1 row in set (0.00 sec)
sql
SELECT id, tugas_id, status_lama, status_baru, waktu, catatan
FROM riwayat_status
WHERE tugas_id = 3
ORDER BY id;

Output yang Dihasilkan:

text
+----+----------+-------------+-------------+---------------------+--------------------------------+
| id | tugas_id | status_lama | status_baru | waktu               | catatan                        |
+----+----------+-------------+-------------+---------------------+--------------------------------+
| 12 |        3 | baru        | berjalan    | 2026-02-01 09:00:00 | Diambil Rina untuk sprint beta |
+----+----------+-------------+-------------+---------------------+--------------------------------+
1 row in set (0.00 sec)

Tugas 3 kini menunjuk Rina dan berstatus berjalan, sementara riwayatnya memuat tepat satu baris perubahan baru → berjalan dengan waktu dan catatan yang lengkap. Waktu dicatat sebagai literal '2026-02-01 09:00:00' — pada aplikasi sungguhan, kolom ini diisi NOW() oleh aplikasi; latihan kita memakai literal agar setiap output dapat diverifikasi ulang.

Menyelesaikan Tugas dan Mencatat Sebabnya #

Skenario kedua menuntaskan alur: tugas 2 — “Implementasi API autentikasi” — selesai dikerjakan Budi. Sama seperti sebelumnya, kolom status dan riwayat bergerak dalam satu transaksi:

sql
START TRANSACTION;

UPDATE tugas
SET status = 'selesai'
WHERE id = 2;

INSERT INTO riwayat_status (tugas_id, status_lama, status_baru, waktu, catatan)
VALUES (2, 'berjalan', 'selesai', '2026-02-08 15:45:00', 'API lulus uji integrasi');

COMMIT;

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

Query OK, 1 row affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Dengan pola yang sama dua kali, alur status lengkap aplikasi sudah terbentuk: baru → berjalan saat diambil, berjalan → selesai saat tuntas. Riwayat tugas 2 kini memuat dua baris yang merekam perjalanan penuhnya — bukti yang akan dipakai laporan di Bagian 4.

Membuktikan ROLLBACK Menyelamatkan Konsistensi #

Sekarang sengaja kita rusakkan. Skenario: aplikasi mencoba menandai tugas 5 selesai, tetapi baris riwayatnya gagal ditulis karena status_baru diisi nilai ENUM yang tidak ada. Tanpa transaksi, UPDATE pertama akan telanjur mengubah status; dengan transaksi, kita batalkan semuanya:

sql
-- @error
START TRANSACTION;

UPDATE tugas
SET status = 'selesai'
WHERE id = 5;

INSERT INTO riwayat_status (tugas_id, status_lama, status_baru, waktu, catatan)
VALUES (5, 'berjalan', 'tuntas', '2026-02-09 10:00:00', 'Salah ketik status');

ROLLBACK;

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

ERROR 1265 (01000): Data truncated for column 'status_baru' at row 1
Query OK, 0 rows affected (0.00 sec)

Pernyataan INSERT ditolak karena 'tuntas' bukan anggota ENUM, dan ROLLBACK mengembalikan perubahan yang telanjur dibuat UPDATE. Status tugas 5 di luar transaksi tidak pernah tersentuh:

sql
SELECT id, judul, status FROM tugas WHERE id = 5;

Output yang Dihasilkan:

text
+----+-----------------------------+----------+
| id | judul                       | status   |
+----+-----------------------------+----------+
|  5 | Skrip migrasi batch pertama | berjalan |
+----+-----------------------------+----------+
1 row in set (0.00 sec)

Tugas 5 tetap berjalan dan tabel riwayat tidak menerima baris baru. Inilah jaminan atomicity yang kita bahas pada Section Transactions, kini terasa nyata manfaatnya: aplikasi boleh mencoba, gagal, dan mencoba lagi tanpa meninggalkan setengah perubahan.

Referensi #

Alur perubahan status kini aman secara struktural. Bagian penutup proyek ini mengubah data yang tertata menjadi informasi: laporan beban kerja, tugas yang menembus tenggat, dan riwayat perjalanan setiap tugas.

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 Manajemen Tugas
-- Data contoh deterministik (6 pengguna, 3 proyek, 10 tugas, riwayat awal).
-- Cara pakai: mysql -u root -p tugas_db < data-awal.sql
-- (jalankan skema.sql terlebih dahulu)

USE tugas_db;

INSERT INTO pengguna (id, nama, email) VALUES
  (1, 'Budi Santoso', '[email protected]'),
  (2, 'Siti Rahma', '[email protected]'),
  (3, 'Andi Wijaya', '[email protected]'),
  (4, 'Dewi Lestari', '[email protected]'),
  (5, 'Tono Pratama', '[email protected]'),
  (6, 'Rina Kusuma', '[email protected]');

INSERT INTO proyek (id, nama, pemilik_id) VALUES
  (1, 'Rilis Aplikasi Mobile', 1),
  (2, 'Migrasi Data Gudang', 2),
  (3, 'Kampanye Pemasaran Q1', 4);

INSERT INTO tugas (id, judul, proyek_id, assignee_id, prioritas, tenggat, status) VALUES
  (1,  'Desain mockup halaman utama',  1, 3,    'tinggi', '2026-02-10', 'selesai'),
  (2,  'Implementasi API autentikasi', 1, 1,    'tinggi', '2026-02-10', 'berjalan'),
  (3,  'Uji coba beta di Play Store',  1, NULL, 'sedang', '2026-03-15', 'baru'),
  (4,  'Pemetaan tabel lama ke baru',  2, 2,    'tinggi', '2026-02-05', 'selesai'),
  (5,  'Skrip migrasi batch pertama', 2, 5,    'sedang', '2026-02-25', 'berjalan'),
  (6,  'Validasi hasil migrasi',      2, 4,    'rendah', '2026-03-01', 'baru'),
  (7,  'Menyusun materi iklan',       3, 4,    'sedang', '2026-02-12', 'selesai'),
  (8,  'Riset kata kunci iklan',      3, 3,    'rendah', '2026-02-08', 'selesai'),
  (9,  'Negosiasi anggaran media',    3, 1,    'tinggi', '2026-02-12', 'berjalan'),
  (10, 'Laporan performa kampanye',   3, NULL, 'sedang', '2026-04-10', 'baru');

INSERT INTO riwayat_status (id, tugas_id, status_lama, status_baru, waktu, catatan) VALUES
  (1,  1, 'baru',     'berjalan', '2026-01-06 10:15:00', 'Mulai kerjakan mockup'),
  (2,  1, 'berjalan', 'selesai',  '2026-02-09 16:40:00', 'Mockup disetujui tim'),
  (3,  2, 'baru',     'berjalan', '2026-01-20 11:00:00', 'Sprint 1 dimulai'),
  (4,  4, 'baru',     'berjalan', '2026-01-07 09:00:00', 'Pemetaan awal berjalan'),
  (5,  4, 'berjalan', 'selesai',  '2026-02-04 17:00:00', 'Pemetaan tervalidasi'),
  (6,  5, 'baru',     'berjalan', '2026-01-15 10:30:00', 'Skrip versi awal mulai dikerjakan'),
  (7,  7, 'baru',     'berjalan', '2026-01-12 10:00:00', 'Brief iklan diterima'),
  (8,  7, 'berjalan', 'selesai',  '2026-02-11 15:30:00', 'Materi disetujui manajemen'),
  (9,  8, 'baru',     'berjalan', '2026-01-13 09:10:00', 'Riset kata kunci dimulai'),
  (10, 8, 'berjalan', 'selesai',  '2026-02-06 12:00:00', 'Riset selesai, 40 kata kunci'),
  (11, 9, 'baru',     'berjalan', '2026-01-25 10:30:00', 'Rapat awal dengan vendor');

database/skema.sql

Unduh berkas
sql
-- skema.sql — Proyek: Database Manajemen Tugas
-- Membangun database tugas_db beserta 4 tabel berelasi (pengguna, proyek,
-- tugas, riwayat_status) lengkap dengan foreign key bernama.
-- Cara pakai: mysql -u root -p < skema.sql

DROP DATABASE IF EXISTS tugas_db;
CREATE DATABASE tugas_db;
USE tugas_db;

CREATE TABLE pengguna (
  id INT AUTO_INCREMENT PRIMARY KEY,
  nama VARCHAR(50) NOT NULL,
  email VARCHAR(60) NOT NULL UNIQUE
);

CREATE TABLE proyek (
  id INT AUTO_INCREMENT PRIMARY KEY,
  nama VARCHAR(60) NOT NULL,
  pemilik_id INT NOT NULL,
  CONSTRAINT fk_proyek_pemilik
    FOREIGN KEY (pemilik_id) REFERENCES pengguna (id)
);

CREATE TABLE tugas (
  id INT AUTO_INCREMENT PRIMARY KEY,
  judul VARCHAR(80) NOT NULL,
  proyek_id INT NOT NULL,
  assignee_id INT NULL,
  prioritas ENUM('rendah', 'sedang', 'tinggi') NOT NULL,
  tenggat DATE NULL,
  status ENUM('baru', 'berjalan', 'selesai') NOT NULL,
  CONSTRAINT fk_tugas_proyek
    FOREIGN KEY (proyek_id) REFERENCES proyek (id),
  CONSTRAINT fk_tugas_assignee
    FOREIGN KEY (assignee_id) REFERENCES pengguna (id)
);

CREATE TABLE riwayat_status (
  id INT AUTO_INCREMENT PRIMARY KEY,
  tugas_id INT NOT NULL,
  status_lama ENUM('baru', 'berjalan', 'selesai') NOT NULL,
  status_baru ENUM('baru', 'berjalan', 'selesai') NOT NULL,
  waktu DATETIME NOT NULL,
  catatan VARCHAR(200),
  CONSTRAINT fk_riwayat_tugas
    FOREIGN KEY (tugas_id) REFERENCES tugas (id)
);

queries/laporan-beban-kerja.sql

Unduh berkas
sql
-- laporan-beban-kerja.sql — Beban kerja tiap anggota (total/selesai/berjalan/baru).
-- Cara pakai: mysql -u root -p tugas_db < queries/laporan-beban-kerja.sql
USE tugas_db;

SELECT
  p.nama,
  COUNT(t.id) AS total_tugas,
  SUM(CASE WHEN t.status = 'selesai' THEN 1 ELSE 0 END) AS selesai,
  SUM(CASE WHEN t.status = 'berjalan' THEN 1 ELSE 0 END) AS berjalan,
  SUM(CASE WHEN t.status = 'baru' THEN 1 ELSE 0 END) AS baru
FROM pengguna p
LEFT JOIN tugas t ON t.assignee_id = p.id
GROUP BY p.id, p.nama
ORDER BY total_tugas DESC, p.nama;

queries/laporan-overdue.sql

Unduh berkas
sql
-- laporan-overdue.sql — Tugas menembus tenggat namun belum selesai.
-- Ganti tanggal acuan sesuai kebutuhan (di aplikasi: CURRENT_DATE()).
-- Cara pakai: mysql -u root -p tugas_db < queries/laporan-overdue.sql
USE tugas_db;

SELECT
  t.id,
  t.judul,
  pr.nama AS proyek,
  p.nama AS penanggung,
  t.tenggat,
  DATEDIFF('2026-02-15', t.tenggat) AS hari_terlambat
FROM tugas t
JOIN proyek pr ON pr.id = t.proyek_id
LEFT JOIN pengguna p ON p.id = t.assignee_id
WHERE t.status <> 'selesai'
  AND t.tenggat < '2026-02-15'
ORDER BY t.tenggat, t.id;

queries/laporan-status-proyek.sql

Unduh berkas
sql
-- laporan-status-proyek.sql — Distribusi status + persentase selesai per proyek.
-- Cara pakai: mysql -u root -p tugas_db < queries/laporan-status-proyek.sql
USE tugas_db;

SELECT
  pr.nama AS proyek,
  COUNT(t.id) AS total_tugas,
  SUM(CASE WHEN t.status = 'selesai' THEN 1 ELSE 0 END) AS selesai,
  ROUND(
    SUM(CASE WHEN t.status = 'selesai' THEN 1 ELSE 0 END) / COUNT(t.id) * 100,
    1
  ) AS persen_selesai
FROM proyek pr
LEFT JOIN tugas t ON t.proyek_id = pr.id
GROUP BY pr.id, pr.nama
ORDER BY persen_selesai DESC, pr.nama;

tests/pemeriksaan.sql

Unduh berkas
sql
-- pemeriksaan.sql — Proyek: Database Manajemen Tugas
-- Rangkaian pemeriksaan otomatis: skema, jumlah data, hasil alur Bagian 3,
-- dan angka laporan Bagian 4. Ekspektasi tertulis di komentar tiap blok.
-- Cara pakai (urutan lengkap membangun kondisi akhir proyek):
--   mysql -u root -p < database/skema.sql
--   mysql -u root -p < database/data-awal.sql        (kondisi awal Bagian 2)
--   ...jalankan alur transaksi Bagian 3...           (atau lihat Bagian 4)
--   mysql -u root -p < tests/pemeriksaan.sql
-- Pemeriksaan ini mengasumsikan kondisi AKHIR (13 baris riwayat).
USE tugas_db;

-- [1] Struktur: 4 tabel harus ada
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'tugas_db'
ORDER BY table_name;
-- Ekspektasi: pengguna, proyek, riwayat_status, tugas (4 baris)

-- [2] Jumlah data kondisi akhir: 6 pengguna, 3 proyek, 10 tugas, 13 riwayat
SELECT
  (SELECT COUNT(*) FROM pengguna) AS pengguna,
  (SELECT COUNT(*) FROM proyek) AS proyek,
  (SELECT COUNT(*) FROM tugas) AS tugas,
  (SELECT COUNT(*) FROM riwayat_status) AS riwayat;
-- Ekspektasi: 6, 3, 10, 13

-- [3] Integritas ENUM: email unik dijaga (duplikat ditolak)
-- (dibuktikan di Bagian 2 dengan ERROR 1062; di sini cukup hitung unik)
SELECT COUNT(DISTINCT email) AS email_unik FROM pengguna;
-- Ekspektasi: 6

-- [4] Alur Bagian 3 terekam: tugas 3 kini milik Rina dan berjalan
SELECT judul, assignee_id, status FROM tugas WHERE id = 3;
-- Ekspektasi: Uji coba beta di Play Store | 6 | berjalan

-- [5] Riwayat tugas 3: tepat 1 baris baru → berjalan
SELECT COUNT(*) AS perubahan_tugas3 FROM riwayat_status WHERE tugas_id = 3;
-- Ekspektasi: 1

-- [6] Durasi siklus tugas 2: selesai 19 hari setelah mulai
SELECT
  TIMESTAMPDIFF(DAY, MIN(waktu), MAX(waktu)) AS durasi_hari
FROM riwayat_status
WHERE tugas_id = 2;
-- Ekspektasi: 19

-- [7] Laporan overdue (acuan 2026-02-15): 4 tugas belum selesai menembus tenggat
SELECT COUNT(*) AS tugas_terlambat
FROM tugas
WHERE status <> 'selesai' AND tenggat < '2026-02-15';
-- Ekspektasi: 4

-- [8] Laporan status proyek: Kampanye Pemasaran Q1 paling tinggi persentase selesainya
SELECT
  pr.nama AS proyek,
  ROUND(
    SUM(CASE WHEN t.status = 'selesai' THEN 1 ELSE 0 END) / COUNT(t.id) * 100,
    1
  ) AS persen_selesai
FROM proyek pr
LEFT JOIN tugas t ON t.proyek_id = pr.id
GROUP BY pr.id, pr.nama
ORDER BY persen_selesai DESC;
-- Ekspektasi: Kampanye Pemasaran Q1 (83.3) teratas, lalu Migrasi Data Gudang, Rilis Aplikasi Mobile