Merancang Basis Data Pasar Mobil Bekas: Tabel, Data Contoh, Transaksi, dan Mockup Aplikasi, 100 Contoh dari Nol hingga Profesional

Merancang Basis Data Pasar Mobil Bekas: 100 Contoh dari Nol hingga Profesional

Pasar mobil bekas menghubungkan pembeli, penjual, dan dealer di sekitar satu aset yang mahal, unik, dan harus dialihkan kepemilikannya secara hukum. Setiap mobil punya nomor rangka (VIN), riwayat kilometer, laporan inspeksi, dan STNK yang harus berganti pemilik dengan benar. Artikel ini merancang basis data untuk pasar seperti itu, dari skema kosong hingga rilis yang siap produksi, dalam 100 contoh. Anda akan menyusun aturan bisnis, membuat tabel pengguna dan dealer, katalog mobil dengan merek, model, dan varian, iklan dengan foto, inspeksi dan pembacaan kilometer, indeks pencarian, penawaran dan penawaran balasan, pesanan dengan alur status yang jelas, pembayaran yang dicatat sebagai buku besar, langkah-langkah balik nama dengan dokumen, pembiayaan dan garansi, ulasan dan laporan penipuan, serta transaksi yang menjaga semuanya tetap konsisten saat dipakai bersamaan. Setiap bagian menyertakan data contoh, apa yang terjadi di dalam transaksi saat dijalankan, dan mockup layar aplikasi yang memakainya. Catatan jujur: MySQL tidak terpasang di komputer yang dipakai menyiapkan artikel ini, jadi 100 contoh tersebut tidak dijalankan di sini. SQL mengikuti sintaks MySQL 8.4, tetapi jalankan sendiri setiap bagiannya di basis data uji sebelum Anda mengandalkannya.

Yang Anda butuhkan

MySQL 8.4 (atau Docker untuk menjalankannya dalam kontainer), klien SQL seperti MySQL Workbench atau perintah mysql, serta pemahaman dasar tentang tabel, kunci, dan join. Gunakan nama orang, email, dan VIN palsu, jangan pernah memakai data pelanggan asli, dan jangan menempelkan kata sandi, kunci API, atau data rekening bank yang asli ke dalam skrip.

Cara memakai contoh

Contoh SQL dijalankan di MySQL 8.4 pada basis data uji, sesuai urutan yang diberikan. Contoh HTML adalah mockup layar yang dapat dibuka di browser. Contoh JSON menunjukkan bentuk data API, dan contoh bash memakai perintah mysql dan curl.

Topik 1: Aturan Bisnis dan Persiapan

Mulailah dari aturan, bukan dari tabel. Siapa yang boleh mendaftarkan mobil, siapa yang boleh membeli, apa yang harus benar sebelum mobil tayang, dan apa yang tidak boleh terjadi, misalnya menjual mobil yang sama dua kali. Basis data menegakkan aturan-aturan ini agar bug di aplikasi tidak dapat merusaknya.

Pelaku dan kebutuhannya

Pembeli mencari, menyimpan favorit, dan mengajukan penawaran. Penjual mendaftarkan mobil dan menjawab penawaran. Dealer mendaftarkan banyak mobil dan membawa lencana terverifikasi. Inspektur menilai mobil, dan admin meninjau laporan. Setiap peran membutuhkan izin dan layarnya sendiri.

Aturan yang harus dijaga basis data

Satu mobil hanya boleh dijual kepada satu pembeli pada satu waktu. Iklan baru tayang setelah inspeksi lulus. Uang muka ditahan sampai balik nama selesai. Setiap perubahan harga dan status dicatat. Tulis aturan ini lebih dulu, lalu putuskan mana yang ditegakkan oleh constraint CHECK, kunci asing, dan transaksi.

Set karakter dan basis data

Buat basis data dengan utf8mb4 agar teks Thai, emoji, dan nama tersimpan persis seperti yang diketik. Gunakan set karakter yang sama di setiap tabel, karena campuran pengodean menyebabkan teks rusak dan bug perbandingan.

Ilustrasi

Yang terjadi saat pembeli membeli mobil bekas: penawaran diterima dan mobil dipesan, uang muka dicatat di buku besar, langkah balik nama diselesaikan dengan dokumen, dan barulah mobil ditandai sebagai terjual
Yang terjadi saat pembeli membeli mobil bekas: penawaran diterima dan mobil dipesan, uang muka dicatat di buku besar, langkah balik nama diselesaikan dengan dokumen, dan barulah mobil ditandai sebagai terjual

Contoh 1: Tulis aturan bisnis lebih dulu

Siapa melakukan apa, dan aturan apa yang harus dijaga basis data.

{
  "buyers": "search, favorite, make an offer, pay a deposit, buy",
  "sellers": "list a car with photos and a price, accept or reject offers",
  "dealers": "list many cars, get a verified badge after a document check",
  "rules": [
    "a car can be sold to one buyer only",
    "a deposit is held until the ownership transfer is done",
    "every price change and status change is recorded"
  ]
}

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 2: Buat basis data

Gunakan utf8mb4 agar teks Thai dan emoji tersimpan dengan benar.

CREATE DATABASE IF NOT EXISTS carmarket
  CHARACTER SET utf8mb4
  COLLATE utf8mb4_unicode_ci;
USE carmarket;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 3: Tabel pengguna

Satu tabel akun untuk pembeli, penjual, dealer, dan staf.

CREATE TABLE users (
  id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  email         VARCHAR(190) NOT NULL UNIQUE,
  phone         VARCHAR(20)  NULL,
  password_hash CHAR(60)     NOT NULL,
  full_name     VARCHAR(120) NOT NULL,
  role          ENUM('buyer','seller','dealer','inspector','admin') NOT NULL DEFAULT 'buyer',
  is_active     TINYINT(1)   NOT NULL DEFAULT 1,
  created_at    TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 4: Pengguna contoh

Akun palsu dengan hash kata sandi pengganti.

INSERT INTO users (email, phone, password_hash, full_name, role) VALUES
  ('[email protected]', '0810000001', '$2b$12$exampleexampleexampleexampleexampleexampleexampleexa', 'Somchai Example', 'seller'),
  ('[email protected]',   '0810000002', '$2b$12$exampleexampleexampleexampleexampleexampleexampleexa', 'Malee Example',   'buyer'),
  ('[email protected]','0810000003', '$2b$12$exampleexampleexampleexampleexampleexampleexampleexa', 'Prime Auto Example', 'dealer'),
  ('[email protected]','0810000004','$2b$12$exampleexampleexampleexampleexampleexampleexampleexa', 'Inspector Example', 'inspector');

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 5: Tabel dealer

Data usaha dipisahkan dari akun masuk.

CREATE TABLE dealers (
  id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  user_id       BIGINT UNSIGNED NOT NULL UNIQUE,
  business_name VARCHAR(160) NOT NULL,
  tax_id        CHAR(13)     NOT NULL UNIQUE,
  is_verified   TINYINT(1)   NOT NULL DEFAULT 0,
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 6: Dealer contoh

Hubungkan usaha yang terverifikasi dengan akun penggunanya.

INSERT INTO dealers (user_id, business_name, tax_id, is_verified)
SELECT id, 'Prime Auto Example Co., Ltd.', '0105500000001', 1
FROM users WHERE email = '[email protected]';

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Topik 2: Pengguna, Dealer, dan Alamat

Setiap orang yang masuk memiliki satu baris di users, dengan peran. Dealer menyimpan detail usaha di tabelnya sendiri, dan alamat memungkinkan pencarian mobil berdasarkan provinsi. Memisahkan tabel-tabel ini membuat setiap tabel kecil dan setiap aturan mudah diperiksa.

Satu tabel akun dengan peran

Tabel users menyimpan email, hash kata sandi, dan peran. Peran seperti penjual, pembeli, dealer, inspektur, atau admin menentukan layar dan tindakan yang diizinkan. Jangan pernah menyimpan kata sandi dalam teks biasa. Simpan hanya hash bcrypt.

Dealer sebagai tabel terpisah

Dealer adalah pengguna dengan usaha yang terdaftar, sehingga nama usaha dan NPWP disimpan di dealers dan menunjuk ke users dengan kunci asing. Bendera is_verified hanya diatur setelah staf memeriksa dokumen, dan hanya dealer terverifikasi yang menampilkan lencana.

Alamat dan preferensi

Provinsi disimpan di setiap alamat dan setiap iklan, dengan indeks agar pencarian satu provinsi cepat. Preferensi pembeli seperti anggaran dan jenis bahan bakar disimpan terpisah, sehingga dapat diubah tanpa menyentuh akun.

Contoh 7: Alamat

Simpan lokasi agar pencarian bisa difilter per provinsi.

CREATE TABLE addresses (
  id           BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  user_id      BIGINT UNSIGNED NOT NULL,
  line1        VARCHAR(200) NOT NULL,
  district     VARCHAR(100) NOT NULL,
  province     VARCHAR(100) NOT NULL,
  postal_code  CHAR(5)      NOT NULL,
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
  INDEX idx_addresses_province (province)
) ENGINE=InnoDB;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 8: Alamat contoh

Alamat penjual di Chonburi.

INSERT INTO addresses (user_id, line1, district, province, postal_code)
SELECT id, '99/1 Example Road', 'Mueang', 'Chonburi', '20000'
FROM users WHERE email = '[email protected]';

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 9: Preferensi pembeli

Simpan anggaran dan jenis bahan bakar yang diinginkan.

CREATE TABLE buyer_preferences (
  user_id      BIGINT UNSIGNED PRIMARY KEY,
  max_budget   DECIMAL(12,2) NULL,
  preferred_fuel ENUM('petrol','diesel','hybrid','ev','lpg','cng') NULL,
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 10: Sesi masuk

Buat sesi kedaluwarsa dan hapus bersama pengguna.

CREATE TABLE sessions (
  id           CHAR(64) PRIMARY KEY,
  user_id      BIGINT UNSIGNED NOT NULL,
  expires_at   DATETIME NOT NULL,
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
  INDEX idx_sessions_expires (expires_at)
) ENGINE=InnoDB;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 11: Merek mobil

Satu baris per merek, namanya unik.

CREATE TABLE car_makes (
  id    SMALLINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  name  VARCHAR(60) NOT NULL UNIQUE
) ENGINE=InnoDB;
INSERT INTO car_makes (name) VALUES ('Toyota'), ('Honda'), ('Mazda'), ('Isuzu'), ('Mitsubishi');

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 12: Model mobil

Setiap model hanya milik satu merek.

CREATE TABLE car_models (
  id           INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  make_id      SMALLINT UNSIGNED NOT NULL,
  name         VARCHAR(80) NOT NULL,
  body_type    ENUM('sedan','hatchback','suv','pickup','van') NOT NULL,
  UNIQUE KEY uq_model (make_id, name),
  FOREIGN KEY (make_id) REFERENCES car_makes(id)
) ENGINE=InnoDB;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 13: Model contoh

Masukkan model dengan join ke tabel merek.

INSERT INTO car_models (make_id, name, body_type)
SELECT m.id, x.name, x.body
FROM car_makes m
JOIN (SELECT 'Toyota' AS make, 'Camry' AS name, 'sedan' AS body
      UNION ALL SELECT 'Toyota', 'Yaris Ativ', 'sedan'
      UNION ALL SELECT 'Honda', 'City', 'sedan'
      UNION ALL SELECT 'Honda', 'CR-V', 'suv'
      UNION ALL SELECT 'Mazda', 'CX-5', 'suv'
      UNION ALL SELECT 'Isuzu', 'D-Max', 'pickup') x ON x.make = m.name;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 14: Varian (trim)

Model yang sama hadir dalam versi dengan mesin berbeda.

CREATE TABLE car_trims (
  id            INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  model_id      INT UNSIGNED NOT NULL,
  name          VARCHAR(80) NOT NULL,
  year_from     SMALLINT UNSIGNED NOT NULL,
  year_to       SMALLINT UNSIGNED NULL,
  engine_cc     SMALLINT UNSIGNED NOT NULL,
  fuel          ENUM('petrol','diesel','hybrid','ev','lpg','cng') NOT NULL,
  transmission  ENUM('auto','manual','cvt') NOT NULL,
  seats         TINYINT UNSIGNED NOT NULL DEFAULT 5,
  FOREIGN KEY (model_id) REFERENCES car_models(id),
  INDEX idx_trims_model_fuel (model_id, fuel)
) ENGINE=InnoDB;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Topik 3: Katalog Mobil: Merek, Model, dan Varian

Katalog menyimpan fakta tentang sebuah model sekali saja, alih-alih mengulanginya di setiap iklan. Satu merek memiliki banyak model, satu model memiliki banyak varian, dan satu varian memiliki mesin, bahan bakar, dan transmisi yang benar-benar dibandingkan oleh pembeli.

Merek, model, varian

Merek seperti Toyota memiliki model seperti Camry. Varian seperti G 2.5 adalah versi dari model itu dengan mesin dan transmisinya sendiri. Model yang sama bisa punya beberapa varian di tahun yang berbeda, jadi rentang tahun harus berada di tingkat varian.

Opsi dan hubungan banyak-ke-banyak

Opsi seperti atap geser atau kamera belakang tidak ada di setiap varian. Tabel penghubung menyambungkan opsi dengan varian, sehingga satu opsi dapat muncul di banyak varian dan satu varian dapat memiliki banyak opsi.

Mengapa katalog penting

Saat penjual memilih varian, data mesin, bahan bakar, dan jumlah kursi diambil dari katalog. Penjual tidak bisa salah mengetik, dan pencarian berdasarkan bahan bakar atau transmisi dapat diandalkan karena nilainya disimpan sebagai kode, bukan teks bebas.

Contoh 15: Varian katalog

Varian bensin otomatis untuk rentang tahun tertentu.

INSERT INTO car_trims (model_id, name, year_from, year_to, engine_cc, fuel, transmission)
SELECT id, 'G 2.5', 2018, 2023, 2487, 'petrol', 'auto'
FROM car_models WHERE name = 'Camry';

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 16: Varian hybrid

Varian hybrid dengan transmisi CVT dan tanpa tahun akhir.

INSERT INTO car_trims (model_id, name, year_from, year_to, engine_cc, fuel, transmission)
SELECT id, 'Hybrid Premium', 2019, NULL, 1798, 'hybrid', 'cvt'
FROM car_models WHERE name = 'Camry';

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 17: Opsi

Atap geser, kamera, asisten jalur, dan jok kulit sebagai kode.

CREATE TABLE car_options (
  id    SMALLINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  code  VARCHAR(40) NOT NULL UNIQUE,
  label VARCHAR(80) NOT NULL
) ENGINE=InnoDB;
INSERT INTO car_options (code, label) VALUES
  ('sunroof', 'Sunroof'), ('rear_camera', 'Rear camera'), ('lane_assist', 'Lane assist'), ('leather', 'Leather seats');

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 18: Opsi per varian

Hubungkan opsi dengan varian lewat tabel banyak-ke-banyak.

CREATE TABLE trim_options (
  trim_id   INT UNSIGNED NOT NULL,
  option_id SMALLINT UNSIGNED NOT NULL,
  PRIMARY KEY (trim_id, option_id),
  FOREIGN KEY (trim_id) REFERENCES car_trims(id) ON DELETE CASCADE,
  FOREIGN KEY (option_id) REFERENCES car_options(id)
) ENGINE=InnoDB;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 19: Daftar mobil dijual

Satu baris per mobil fisik yang dijual, dengan aturan CHECK.

CREATE TABLE car_listings (
  id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  seller_id     BIGINT UNSIGNED NOT NULL,
  trim_id       INT UNSIGNED NOT NULL,
  vin           CHAR(17) NOT NULL UNIQUE,
  plate         VARCHAR(20) NULL,
  year          SMALLINT UNSIGNED NOT NULL,
  mileage_km    INT UNSIGNED NOT NULL,
  color         VARCHAR(40) NOT NULL,
  asking_price  DECIMAL(12,2) NOT NULL,
  status        ENUM('draft','pending_review','active','reserved','sold','removed') NOT NULL DEFAULT 'draft',
  province      VARCHAR(100) NOT NULL,
  created_at    TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at    TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  FOREIGN KEY (seller_id) REFERENCES users(id),
  FOREIGN KEY (trim_id) REFERENCES car_trims(id),
  CHECK (asking_price > 0),
  CHECK (mileage_km >= 0)
) ENGINE=InnoDB;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 20: Daftar aktif contoh

Mobil Camry 2019 yang dijual di Chonburi.

INSERT INTO car_listings (seller_id, trim_id, vin, plate, year, mileage_km, color, asking_price, status, province)
SELECT u.id, t.id, 'JTNB11HK103000001', 'กข 1234', 2019, 48250, 'White', 765000.00, 'active', 'Chonburi'
FROM users u, car_trims t
WHERE u.email = '[email protected]' AND t.name = 'G 2.5' AND t.year_from = 2018
LIMIT 1;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 21: Foto mobil

Simpan URL foto dengan urutan tampil yang tetap.

CREATE TABLE car_images (
  id           BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  listing_id   BIGINT UNSIGNED NOT NULL,
  url          VARCHAR(500) NOT NULL,
  position     TINYINT UNSIGNED NOT NULL DEFAULT 0,
  FOREIGN KEY (listing_id) REFERENCES car_listings(id) ON DELETE CASCADE,
  UNIQUE KEY uq_image_position (listing_id, position)
) ENGINE=InnoDB;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 22: Foto contoh

Tiga foto untuk daftar pertama.

INSERT INTO car_images (listing_id, url, position) VALUES
  (1, 'https://cdn.example.com/cars/1/front.jpg', 0),
  (1, 'https://cdn.example.com/cars/1/side.jpg', 1),
  (1, 'https://cdn.example.com/cars/1/interior.jpg', 2);

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Topik 4: Iklan dan Foto

Iklan adalah satu mobil fisik yang dijual. Ia memiliki VIN, kilometer, warna, harga, dan statusnya sendiri. Foto berada di tabel terpisah dengan urutan tetap, sehingga foto pertama selalu tampak depan dan galeri selalu dimuat dengan urutan yang sama.

Satu baris per mobil fisik

Setiap mobil memiliki VIN yang unik, sehingga mobil yang sama tidak bisa didaftarkan dua kali. Statusnya bergerak dari draf ke menunggu tinjauan, lalu ke aktif, dipesan, terjual, atau dihapus. Aturan CHECK mencegah harga atau kilometer negatif tersimpan.

Mengubah iklan dengan aman

Hanya penjual yang boleh mengubah harga. Letakkan pemeriksaan penjual dalam pernyataan UPDATE yang sama dengan perubahannya, sehingga tidak ada celah antara pemeriksaan dan penulisan. Ubah status hanya sepanjang langkah yang diizinkan, dan catat setiap perubahan.

Foto dan halaman detail

Simpan URL foto dan posisinya, bukan file gambarnya. Halaman detail memuat mobil, penjual, dan foto dalam satu kueri, dan mockup menunjukkan bagaimana hasilnya tertata di ponsel dan di desktop.

Contoh 23: Daftar draf

Penjual membuat mobil sebelum daftar dipublikasikan.

INSERT INTO car_listings (seller_id, trim_id, vin, year, mileage_km, color, asking_price, status, province)
SELECT u.id, t.id, 'JTNB11HK103000002', 2020, 31000, 'Silver', 720000.00, 'draft', 'Bangkok'
FROM users u, car_trims t
WHERE u.email = '[email protected]' AND t.name = 'G 2.5'
LIMIT 1;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 24: Ubah harga sebagai pemilik

Periksa penjual dalam pernyataan yang sama.

UPDATE car_listings
SET asking_price = 745000.00
WHERE id = 2 AND seller_id = (SELECT id FROM users WHERE email = '[email protected]');

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 25: Majukan status daftar

Izinkan perubahan status hanya dari langkah sebelumnya.

UPDATE car_listings
SET status = 'pending_review'
WHERE id = 2 AND status = 'draft';

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 26: Daftar aktif per provinsi

Gabungkan katalog untuk menampilkan merek, model, dan varian.

SELECT l.id, l.year, l.mileage_km, l.asking_price, t.name AS trim, m.name AS model, mk.name AS make
FROM car_listings l
JOIN car_trims t  ON t.id = l.trim_id
JOIN car_models m ON m.id = t.model_id
JOIN car_makes mk ON mk.id = m.make_id
WHERE l.status = 'active' AND l.province = 'Chonburi'
ORDER BY l.asking_price;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 27: Halaman detail daftar

Muat mobil, foto, dan penjual dalam satu kueri.

SELECT l.*, u.full_name AS seller_name, GROUP_CONCAT(i.url ORDER BY i.position) AS photos
FROM car_listings l
JOIN users u ON u.id = l.seller_id
LEFT JOIN car_images i ON i.listing_id = l.id
WHERE l.id = 1
GROUP BY l.id;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 28: Mockup: kartu hasil pencarian

Kartu mobil dengan harga, spesifikasi, dan tombol tawar.

<article class="car-card">
  <img src="https://cdn.example.com/cars/1/front.jpg" alt="Toyota Camry 2019 white">
  <h3>Toyota Camry G 2.5 (2019)</h3>
  <p class="meta">48,250 km · Auto · Petrol · Chonburi</p>
  <p class="price">฿765,000</p>
  <button>Make an offer</button>
  <button class="icon" aria-label="Save to favorites">♡</button>
</article>

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 29: Mockup: halaman detail

Galeri, ringkasan, dan tombol dalam satu layar.

<main class="detail">
  <section class="gallery">
    <img src="front.jpg" alt="Front view">
    <div class="thumbs"><img src="side.jpg" alt="Side view"><img src="interior.jpg" alt="Interior"></div>
  </section>
  <aside class="summary">
    <h1>Toyota Camry G 2.5 (2019)</h1>
    <p class="price">฿765,000</p>
    <ul>
      <li>Mileage: 48,250 km</li>
      <li>Color: White</li>
      <li>Province: Chonburi</li>
      <li>Inspection score: <strong>4.5 / 5</strong></li>
    </ul>
    <button class="primary">Make an offer</button>
    <button>Ask the seller</button>
  </aside>
</main>

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 30: Mockup: warna dan jarak

Token desain sebagai variabel CSS.

<style>
  :root {
    --brand: #1f5fbf;
    --ink: #1b1f2a;
    --muted: #6b7280;
    --radius: 12px;
    --gap: 16px;
  }
  .car-card { border: 1px solid #e5e7eb; border-radius: var(--radius); padding: var(--gap); }
  .price { color: var(--brand); font-size: 1.25rem; font-weight: 700; }
  .meta { color: var(--muted); }
</style>

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Topik 5: Inspeksi dan Kondisi Mobil

Nilai mobil bekas hanya ditentukan oleh kondisinya. Inspektur menilai mobil, memberi nilai pada setiap bagian, dan melampirkan laporan. Iklan hanya tayang jika inspeksi lulus, sehingga pembeli melihat fakta yang sama dengan yang dicatat inspektur.

Inspeksi dan item-itemnya

Inspeksi memiliki nilai keseluruhan dari 0 sampai 5 dan laporan PDF. Setiap bagian, seperti mesin, rem, atau ban, memiliki barisnya sendiri dengan nilai baik, cukup, perbaiki, atau ganti. Nilai-nilai ini menjelaskan skor, sehingga pembeli dapat melihat di mana masalahnya.

Pembacaan kilometer

Simpan setiap pembacaan kilometer dari penjual, inspektur, dan catatan servis. Jika pembacaan baru lebih rendah dari yang lama, odometer mungkin telah diputar mundur, sehingga sistem menandai iklan untuk ditinjau alih-alih menampilkan angka yang salah.

Tayang setelah pemeriksaan

Iklan berubah menjadi aktif hanya jika ada inspeksi dengan skor minimal 3,0. Aturannya ada di basis data dan juga di aplikasi, sehingga tidak ada jalur kode yang dapat menayangkan mobil yang belum diinspeksi.

Contoh 31: Inspeksi

Inspektur bersertifikat memberi nilai untuk setiap mobil.

CREATE TABLE inspections (
  id             BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  listing_id     BIGINT UNSIGNED NOT NULL,
  inspector_id   BIGINT UNSIGNED NOT NULL,
  overall_score  DECIMAL(2,1) NOT NULL,
  report_url     VARCHAR(500) NULL,
  inspected_at   DATETIME NOT NULL,
  FOREIGN KEY (listing_id) REFERENCES car_listings(id),
  FOREIGN KEY (inspector_id) REFERENCES users(id),
  CHECK (overall_score BETWEEN 0 AND 5)
) ENGINE=InnoDB;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 32: Contoh inspeksi

Nilai 4,5 dari 5 dengan laporan PDF.

INSERT INTO inspections (listing_id, inspector_id, overall_score, report_url, inspected_at)
SELECT 1, u.id, 4.5, 'https://cdn.example.com/reports/1.pdf', NOW()
FROM users u WHERE u.email = '[email protected]';

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 33: Item inspeksi

Beri nilai setiap bagian: baik, cukup, perbaiki, atau ganti.

CREATE TABLE inspection_items (
  id             BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  inspection_id  BIGINT UNSIGNED NOT NULL,
  part           VARCHAR(60) NOT NULL,
  grade          ENUM('good','fair','repair','replace') NOT NULL,
  note           VARCHAR(255) NULL,
  FOREIGN KEY (inspection_id) REFERENCES inspections(id) ON DELETE CASCADE
) ENGINE=InnoDB;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 34: Contoh item inspeksi

Kampas rem tersisa 40 persen dan goresan di pintu.

INSERT INTO inspection_items (inspection_id, part, grade, note) VALUES
  (1, 'engine', 'good', 'No leaks'),
  (1, 'brakes', 'fair', 'Front pads at 40 percent'),
  (1, 'tyres',  'good', 'Above 4 mm tread'),
  (1, 'paint',  'repair', 'Small scratch on the rear door');

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 35: Cari bagian yang perlu diperbaiki

Hitung jumlah masalah per daftar.

SELECT i.listing_id, COUNT(*) AS problems
FROM inspection_items ii
JOIN inspections i ON i.id = ii.inspection_id
WHERE ii.grade IN ('repair','replace')
GROUP BY i.listing_id;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 36: Tayang setelah lolos inspeksi

Daftar hanya tayang jika nilainya lulus.

UPDATE car_listings l
SET l.status = 'active'
WHERE l.id = 2
  AND l.status = 'pending_review'
  AND EXISTS (SELECT 1 FROM inspections i WHERE i.listing_id = l.id AND i.overall_score >= 3.0);

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 37: Mockup: laporan inspeksi

Nilai yang tampil di halaman daftar.

<section class="inspection">
  <h2>Inspection report</h2>
  <p>Overall score <strong>4.5 / 5</strong> · Inspected 12 Oct 2026</p>
  <ul class="grades">
    <li class="good">Engine: good</li>
    <li class="fair">Brakes: fair, front pads at 40%</li>
    <li class="good">Tyres: good</li>
    <li class="repair">Paint: small scratch on the rear door</li>
  </ul>
  <a href="https://cdn.example.com/reports/1.pdf">Download the full PDF</a>
</section>

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 38: Riwayat kilometer

Simpan setiap pembacaan untuk mendeteksi odometer yang diputar mundur.

CREATE TABLE mileage_readings (
  id           BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  listing_id   BIGINT UNSIGNED NOT NULL,
  reading_km   INT UNSIGNED NOT NULL,
  source       ENUM('seller','inspector','service_record') NOT NULL,
  read_at      DATETIME NOT NULL,
  FOREIGN KEY (listing_id) REFERENCES car_listings(id) ON DELETE CASCADE,
  INDEX idx_mileage_listing_time (listing_id, read_at)
) ENGINE=InnoDB;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Topik 6: Pencarian, Filter, dan Performa

Pembeli mencari berdasarkan provinsi, harga, tahun, bahan bakar, dan transmisi, dan mengharapkan hasil dalam waktu jauh di bawah satu detik. Indeks pada kolom yang muncul di WHERE dan ORDER BY membuat hal ini mungkin, dan paginasi keyset menjaga halaman-halaman dalam tetap cepat.

Indeks yang cocok dengan kueri

Indeks komposit pada status, provinsi, harga, dan tahun melayani pencarian umum. Urutan kolom penting: letakkan kolom kesetaraan di depan dan kolom rentang di belakang. EXPLAIN menunjukkan apakah indeks digunakan, dan EXPLAIN ANALYZE menunjukkan waktu sebenarnya.

Pencarian teks penuh dan filter berjoin

Indeks FULLTEXT pada deskripsi mendukung kata seperti sunroof atau kulit. Bahan bakar dan transmisi ada di tabel varian, jadi pencarian melakukan join ke tabel itu, dan join memakai indeks varian.

Paginasi keyset dan pencarian tersimpan

OFFSET membaca dan membuang setiap baris sebelumnya, sehingga halaman yang lebih dalam semakin lambat. Paginasi keyset mengingat waktu pembuatan dan id terakhir, lalu melanjutkan dari sana. Pencarian tersimpan menyimpan filter sebagai JSON agar pembeli dapat menerima iklan baru yang cocok.

Contoh 39: Indeks pencarian

Indeks status, provinsi, harga, dan tahun sekaligus.

CREATE INDEX idx_listings_search
  ON car_listings (status, province, asking_price, year);
EXPLAIN SELECT id, asking_price FROM car_listings
WHERE status = 'active' AND province = 'Chonburi' AND asking_price BETWEEN 500000 AND 900000;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 40: Pencarian teks penuh

Cari deskripsi dengan MATCH AGAINST.

ALTER TABLE car_listings ADD COLUMN description TEXT NULL,
  ADD FULLTEXT INDEX ft_listings_description (description);
SELECT id, asking_price FROM car_listings
WHERE status = 'active' AND MATCH(description) AGAINST ('sunroof leather' IN NATURAL LANGUAGE MODE);

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 41: Filter bahan bakar dan transmisi

Join tabel varian untuk mencari mobil hybrid CVT.

SELECT l.id, l.asking_price, t.fuel, t.transmission
FROM car_listings l
JOIN car_trims t ON t.id = l.trim_id
WHERE l.status = 'active' AND t.fuel = 'hybrid' AND t.transmission = 'cvt'
ORDER BY l.asking_price
LIMIT 20;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 42: Paginasi keyset

Telusuri hasil tanpa OFFSET yang mahal.

SELECT id, asking_price, created_at
FROM car_listings
WHERE status = 'active'
  AND (created_at, id) < ('2026-10-01 10:00:00', 1000)
ORDER BY created_at DESC, id DESC
LIMIT 20;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 43: Pencarian tersimpan

Simpan filter yang dipilih pembeli sebagai JSON.

CREATE TABLE saved_searches (
  id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  user_id     BIGINT UNSIGNED NOT NULL,
  filters     JSON NOT NULL,
  created_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;
INSERT INTO saved_searches (user_id, filters)
SELECT id, JSON_OBJECT('make','Toyota','fuel','hybrid','max_price',900000)
FROM users WHERE email = '[email protected]';

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 44: Mockup: sidebar filter

Filter merek, harga, tahun, dan bahan bakar dengan tombol tampilkan.

<aside class="filters">
  <h3>Filters</h3>
  <label>Make <select><option>Toyota</option><option>Honda</option></select></label>
  <label>Price (THB) <input type="range" min="100000" max="2000000"></label>
  <label>Year from <input type="number" value="2018"></label>
  <fieldset>
    <legend>Fuel</legend>
    <label><input type="checkbox" checked> Hybrid</label>
    <label><input type="checkbox"> Petrol</label>
  </fieldset>
  <button>Show 24 cars</button>
</aside>

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 45: Hitung mobil per merek

Tampilkan jumlah di samping setiap pilihan.

SELECT mk.name AS make, COUNT(*) AS cars
FROM car_listings l
JOIN car_trims t ON t.id = l.trim_id
JOIN car_models m ON m.id = t.model_id
JOIN car_makes mk ON mk.id = m.make_id
WHERE l.status = 'active'
GROUP BY mk.name
ORDER BY cars DESC;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 46: Periksa rencana kueri

Gunakan EXPLAIN ANALYZE untuk melihat apakah indeks dipakai.

EXPLAIN ANALYZE
SELECT id FROM car_listings
WHERE status = 'active' AND province = 'Chonburi'
ORDER BY asking_price
LIMIT 20;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Topik 7: Penawaran dan Tawar-Menawar

Penawaran adalah usulan harga pembeli untuk sebuah mobil, dengan waktu kedaluwarsa. Harga yang diminta tetap sama, sehingga beberapa pembeli dapat mengajukan penawaran, dan penjual dapat menerima satu, menolak yang lain, atau membalas dengan harga baru.

Penawaran sebagai tabel tersendiri

Penawaran adalah satu baris dengan iklan, pembeli, jumlah, status, dan waktu kedaluwarsa. Penawaran balasan dari penjual adalah baris baru yang menunjuk ke penawaran pembeli, sehingga seluruh negosiasi tetap terlihat dalam riwayat.

Harga minimum dan pemeriksaan

Penjual dapat menetapkan harga minimum. Bandingkan penawaran dengan harga minimum sebelum menampilkannya kepada penjual sebagai penawaran yang dapat diterima. Simpan keputusan beserta alasannya, sehingga sengketa dapat dijawab kemudian.

Kedaluwarsa dan mockup

Tugas terjadwal menandai penawaran lama sebagai kedaluwarsa. Dialog ajukan penawaran menampilkan waktu kedaluwarsa dan menjelaskan apa yang dapat dilakukan penjual, sehingga pembeli tahu apa yang terjadi berikutnya.

Contoh 47: Tawaran

Pembeli mengajukan harga; harga jual tetap sama.

CREATE TABLE offers (
  id           BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  listing_id   BIGINT UNSIGNED NOT NULL,
  buyer_id     BIGINT UNSIGNED NOT NULL,
  amount       DECIMAL(12,2) NOT NULL,
  status       ENUM('open','accepted','rejected','withdrawn','expired') NOT NULL DEFAULT 'open',
  expires_at   DATETIME NOT NULL,
  created_at   TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (listing_id) REFERENCES car_listings(id),
  FOREIGN KEY (buyer_id) REFERENCES users(id),
  INDEX idx_offers_listing_status (listing_id, status),
  CHECK (amount > 0)
) ENGINE=InnoDB;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 48: Tawaran contoh

Pembeli menawar 720.000 baht selama 48 jam.

INSERT INTO offers (listing_id, buyer_id, amount, expires_at)
SELECT 1, u.id, 720000.00, DATE_ADD(NOW(), INTERVAL 48 HOUR)
FROM users u WHERE u.email = '[email protected]';

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 49: Mockup: dialog ajukan tawaran

Kolom jumlah, catatan kedaluwarsa, dan tombol kirim.

<dialog id="offer">
  <form method="dialog">
    <h2>Make an offer</h2>
    <p>Asking price: ฿765,000</p>
    <label>Your offer (THB)
      <input type="number" min="1" step="1000" value="720000" required>
    </label>
    <p class="hint">The offer expires in 48 hours. The seller can accept, reject or counter.</p>
    <button value="cancel">Cancel</button>
    <button value="send" class="primary">Send offer</button>
  </form>
</dialog>

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 50: Tolak tawaran rendah

Bandingkan tawaran dengan harga minimum yang diizinkan penjual.

ALTER TABLE car_listings ADD COLUMN min_offer DECIMAL(12,2) NULL;
UPDATE car_listings SET min_offer = 700000.00 WHERE id = 1;
SELECT CASE WHEN 650000.00 < min_offer THEN 'reject: below minimum' ELSE 'accept for review' END AS decision
FROM car_listings WHERE id = 1;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 51: Terima tawaran dalam satu transaksi

Kunci mobil, terima tawaran, dan pesan daftar bersama-sama.

START TRANSACTION;
SELECT id, status FROM car_listings WHERE id = 1 FOR UPDATE;
UPDATE offers SET status = 'accepted' WHERE id = 1 AND status = 'open';
UPDATE car_listings SET status = 'reserved' WHERE id = 1 AND status = 'active';
COMMIT;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 52: Tolak tawaran lain

Saat satu tawaran menang, tawaran lain untuk mobil yang sama ditutup.

UPDATE offers
SET status = 'rejected'
WHERE listing_id = 1 AND status = 'open' AND id <> 1;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 53: Kedaluwarsakan tawaran lama

Tugas terjadwal menutup tawaran yang melewati batas waktunya.

UPDATE offers
SET status = 'expired'
WHERE status = 'open' AND expires_at < NOW();

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 54: Tawaran balasan dari penjual

Simpan balasan penjual sebagai tawaran baru yang terkait dengan tawaran pembeli.

ALTER TABLE offers ADD COLUMN parent_offer_id BIGINT UNSIGNED NULL,
  ADD COLUMN made_by ENUM('buyer','seller') NOT NULL DEFAULT 'buyer';
INSERT INTO offers (listing_id, buyer_id, amount, expires_at, parent_offer_id, made_by)
SELECT 1, buyer_id, 735000.00, DATE_ADD(NOW(), INTERVAL 24 HOUR), id, 'seller'
FROM offers WHERE id = 1;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Topik 8: Pesanan dan Alur Status

Ketika penjual menerima penawaran, sebuah pesanan dibuat. Pesanan bergerak melalui serangkaian status tetap, dan setiap perubahan dicatat, sehingga staf dukungan selalu dapat melihat bagaimana sebuah penjualan sampai pada status saat ini.

Satu pesanan terkonfirmasi per mobil

Kolom listing_id di orders memiliki constraint UNIQUE, sehingga basis data menolak pesanan kedua untuk mobil yang sama, bahkan jika dua permintaan datang pada saat yang sama. Uang muka yang harus dibayar dihitung dari harga akhir, di sini 10 persen.

Membuat pesanan dalam transaksi yang sama

Menerima penawaran, memesan iklan, dan menyisipkan pesanan terjadi di dalam satu transaksi. Jika salah satu langkah gagal, semuanya di-rollback, sehingga mobil tidak pernah dipesan tanpa pesanan, dan tidak ada pesanan tanpa mobil yang dipesan.

Riwayat status dan langkah yang diizinkan

Setiap perubahan pada pesanan ditulis ke tabel riwayat dengan status lama, status baru, pelaku, dan waktu. UPDATE memeriksa status saat ini, sehingga pesanan tidak dapat melompat dari menunggu uang muka langsung ke selesai.

Contoh 55: Tabel pesanan

Satu pembelian terkonfirmasi per mobil, dengan harga akhir dan uang muka.

CREATE TABLE orders (
  id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  listing_id    BIGINT UNSIGNED NOT NULL UNIQUE,
  buyer_id      BIGINT UNSIGNED NOT NULL,
  seller_id     BIGINT UNSIGNED NOT NULL,
  final_price   DECIMAL(12,2) NOT NULL,
  deposit_due   DECIMAL(12,2) NOT NULL,
  status        ENUM('pending_deposit','deposit_paid','documents_pending','completed','cancelled','refunded') NOT NULL DEFAULT 'pending_deposit',
  created_at    TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (listing_id) REFERENCES car_listings(id),
  FOREIGN KEY (buyer_id) REFERENCES users(id),
  FOREIGN KEY (seller_id) REFERENCES users(id),
  CHECK (final_price > 0),
  CHECK (deposit_due <= final_price)
) ENGINE=InnoDB;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 56: Buat pesanan dari tawaran yang diterima

Sisipkan pesanan dalam transaksi yang sama dengan penerimaan tawaran.

START TRANSACTION;
SELECT status FROM car_listings WHERE id = 1 FOR UPDATE;
INSERT INTO orders (listing_id, buyer_id, seller_id, final_price, deposit_due)
SELECT o.listing_id, o.buyer_id, l.seller_id, o.amount, ROUND(o.amount * 0.10, 2)
FROM offers o JOIN car_listings l ON l.id = o.listing_id
WHERE o.id = 1 AND o.status = 'accepted';
COMMIT;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 57: Riwayat status pesanan

Catat setiap perubahan status beserta pelakunya dan waktunya.

CREATE TABLE order_status_history (
  id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  order_id    BIGINT UNSIGNED NOT NULL,
  from_status VARCHAR(30) NULL,
  to_status   VARCHAR(30) NOT NULL,
  actor_id    BIGINT UNSIGNED NULL,
  changed_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE,
  INDEX idx_history_order (order_id, changed_at)
) ENGINE=InnoDB;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 58: Majukan pesanan satu langkah

Ubah status hanya dari langkah yang diharapkan.

UPDATE orders
SET status = 'deposit_paid'
WHERE id = 1 AND status = 'pending_deposit';
INSERT INTO order_status_history (order_id, from_status, to_status, actor_id)
VALUES (1, 'pending_deposit', 'deposit_paid', 2);

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 59: Batalkan pesanan dan lepas mobil

Kembalikan daftar menjadi aktif jika pesanan dibatalkan lebih awal.

START TRANSACTION;
UPDATE orders SET status = 'cancelled' WHERE id = 1 AND status IN ('pending_deposit','deposit_paid');
UPDATE car_listings SET status = 'active' WHERE id = 1 AND status = 'reserved';
COMMIT;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 60: Mockup: halaman progres pesanan

Langkah dari tawaran diterima sampai penjualan selesai.

<section class="order">
  <h2>Order #1 · Toyota Camry G 2.5 (2019)</h2>
  <ol class="steps">
    <li class="done">Offer accepted</li>
    <li class="done">Deposit paid ฿76,500</li>
    <li class="current">Documents in progress</li>
    <li>Ownership transferred</li>
    <li>Completed</li>
  </ol>
  <button>Cancel order</button>
  <p class="hint">You can cancel until the documents step starts.</p>
</section>

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Topik 9: Pembayaran sebagai Buku Besar

Uang tidak pernah disimpan sebagai satu angka yang terus diedit. Setiap pembayaran, pengembalian, dan pelunasan adalah baris di buku besar. Totalnya dihitung dari baris-baris itu, sehingga riwayatnya selalu lengkap dan angkanya selalu cocok.

Satu baris per pergerakan uang

Pembayaran memiliki jenis, seperti uang muka, pelunasan, atau pengembalian, sebuah jumlah, referensi dari penyedia pembayaran, dan status. Referensi penyedia bersifat UNIQUE, sehingga pembayaran yang sama tidak bisa dicatat dua kali, dan baris itu terhubung dengan catatan bank atau penyedia pembayaran.

Webhook yang datang dua kali

Penyedia pembayaran dapat mengirim notifikasi yang sama lebih dari sekali. Pembaruan hanya mengubah status ketika baris masih tertunda, sehingga pesan kedua tidak mengubah apa pun. Ini membuat penangan aman untuk dicoba ulang.

Pengembalian dan jumlah bersih yang dibayar

Pengembalian adalah baris baru, bukan pembayaran negatif yang menimpa yang lama. Jumlah bersih yang dibayar untuk pesanan adalah jumlah pembayaran yang berhasil dikurangi pengembalian yang berhasil. Pemeriksaan sebelum rilis menghentikan rilis jika jumlah bersih yang dibayar pernah melebihi harga akhir.

Contoh 61: Tabel pembayaran

Setiap pergerakan uang adalah satu baris dengan referensi penyedia pembayaran.

CREATE TABLE payments (
  id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  order_id      BIGINT UNSIGNED NOT NULL,
  kind          ENUM('deposit','balance','refund') NOT NULL,
  amount        DECIMAL(12,2) NOT NULL,
  provider      VARCHAR(40) NOT NULL,
  provider_ref  VARCHAR(100) NOT NULL UNIQUE,
  status        ENUM('pending','succeeded','failed') NOT NULL DEFAULT 'pending',
  created_at    TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (order_id) REFERENCES orders(id),
  CHECK (amount > 0)
) ENGINE=InnoDB;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 62: Catat uang muka sebelum memanggil pembayaran

Simpan pembayaran sebagai tertunda lebih dulu, lalu perbarui.

INSERT INTO payments (order_id, kind, amount, provider, provider_ref, status)
VALUES (1, 'deposit', 76500.00, 'example-pay', 'ref-0001', 'pending');

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 63: Tangani webhook pembayaran yang berulang

Perbarui sekali saja sehingga pesan ganda tidak mengubah apa pun.

UPDATE payments
SET status = 'succeeded'
WHERE provider_ref = 'ref-0001' AND status = 'pending';
-- 0 rows changed on the second call, so the order is not moved twice
UPDATE orders SET status = 'deposit_paid'
WHERE id = 1 AND status = 'pending_deposit'
  AND (SELECT status FROM payments WHERE provider_ref = 'ref-0001') = 'succeeded';

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 64: Total bersih yang dibayar per pesanan

Jumlahkan uang muka dan pelunasan, kurangi pengembalian, dari buku besar.

SELECT o.id,
  o.final_price,
  COALESCE(SUM(CASE WHEN p.kind IN ('deposit','balance') AND p.status = 'succeeded' THEN p.amount END), 0)
  - COALESCE(SUM(CASE WHEN p.kind = 'refund' AND p.status = 'succeeded' THEN p.amount END), 0) AS net_paid
FROM orders o
LEFT JOIN payments p ON p.order_id = o.id
WHERE o.id = 1
GROUP BY o.id, o.final_price;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 65: Kembalikan uang muka

Sisipkan baris pengembalian yang terkait dengan uang muka asli.

INSERT INTO payments (order_id, kind, amount, provider, provider_ref, status)
SELECT order_id, 'refund', amount, provider, CONCAT('refund-', provider_ref), 'pending'
FROM payments
WHERE order_id = 1 AND kind = 'deposit' AND status = 'succeeded';

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 66: Mockup: layar pembayaran uang muka

Jumlah uang muka, catatan penahanan, dan tombol bayar.

<form class="payment">
  <h2>Pay the deposit</h2>
  <p>Deposit (10%): <strong>฿76,500</strong></p>
  <p class="hint">Held until the ownership transfer is done. Refunded if the sale fails.</p>
  <label>Card number <input name="card" autocomplete="cc-number" placeholder="•••• •••• •••• ••••"></label>
  <button type="submit" class="primary">Pay ฿76,500</button>
</form>

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Topik 10: Balik Nama dan Dokumen

Di banyak negara, mobil hanya berganti pemilik di kantor perhubungan, dengan dokumen yang ditandatangani. Basis data memodelkan ini sebagai daftar langkah, masing-masing dengan dokumennya, sehingga pembeli dapat melihat apa yang tersisa dan staf dapat memverifikasi setiap dokumen sebelum mobil ditandai terjual.

Langkah sebagai baris

Setiap langkah balik nama memiliki nomor, nama, dan status: belum dikerjakan, sedang dikerjakan, selesai, atau terblokir. Pesanan hanya selesai ketika semua langkah selesai, yang diperiksa dalam transaksi yang sama dengan penandaan mobil sebagai terjual.

Dokumen dan verifikasi

Setiap dokumen memiliki jenis, seperti perjanjian jual beli atau buku registrasi, URL file, pengunggah, dan waktu verifikasi. Sebuah langkah hanya dapat ditandai selesai jika dokumennya telah diverifikasi, sehingga foto formulir yang belum diperiksa tidak cukup.

Daftar periksa untuk pembeli

Mockup menampilkan daftar periksa dengan centang dan tombol unggah untuk pembeli. Daftar periksa itu dibuat dari tabel langkah, sehingga layar dan basis data selalu sepakat tentang apa yang sudah selesai.

Contoh 67: Langkah pengalihan kepemilikan

Satu baris untuk setiap langkah perubahan pemilik.

CREATE TABLE transfer_steps (
  id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  order_id    BIGINT UNSIGNED NOT NULL,
  step_no     TINYINT UNSIGNED NOT NULL,
  name        VARCHAR(80) NOT NULL,
  status      ENUM('todo','doing','done','blocked') NOT NULL DEFAULT 'todo',
  done_at     DATETIME NULL,
  UNIQUE KEY uq_step (order_id, step_no),
  FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE
) ENGINE=InnoDB;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 68: Langkah standar untuk pesanan

Lima langkah dari perjanjian jual beli sampai serah terima kunci.

INSERT INTO transfer_steps (order_id, step_no, name) VALUES
  (1, 1, 'Seller signs the sale agreement'),
  (1, 2, 'Buyer checks the registration book'),
  (1, 3, 'Land Transport Office appointment booked'),
  (1, 4, 'Ownership changed at the office'),
  (1, 5, 'Balance paid and keys handed over');

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 69: Dokumen

Simpan setiap dokumen yang diunggah beserta jenis dan waktu verifikasinya.

CREATE TABLE documents (
  id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  order_id      BIGINT UNSIGNED NOT NULL,
  doc_type      ENUM('sale_agreement','registration_book','id_card','tax_receipt','transfer_form') NOT NULL,
  file_url      VARCHAR(500) NOT NULL,
  uploaded_by   BIGINT UNSIGNED NOT NULL,
  verified_at   DATETIME NULL,
  FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE,
  FOREIGN KEY (uploaded_by) REFERENCES users(id)
) ENGINE=InnoDB;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 70: Selesaikan langkah hanya dengan dokumen terverifikasi

Periksa dokumen sebelum menandai langkah selesai.

UPDATE transfer_steps s
SET s.status = 'done', s.done_at = NOW()
WHERE s.order_id = 1 AND s.step_no = 1
  AND EXISTS (SELECT 1 FROM documents d
              WHERE d.order_id = s.order_id AND d.doc_type = 'sale_agreement' AND d.verified_at IS NOT NULL);

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 71: Mockup: daftar periksa pengalihan

Kotak centang untuk setiap langkah dengan tombol unggah.

<ul class="checklist">
  <li><input type="checkbox" checked disabled> Seller signed the sale agreement</li>
  <li><input type="checkbox" checked disabled> Registration book checked</li>
  <li><input type="checkbox"> Book an appointment at the office</li>
  <li><input type="checkbox"> Ownership changed</li>
  <li><input type="checkbox"> Balance paid</li>
</ul>
<button>Upload a document</button>

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 72: Ubah kepemilikan di akhir

Selesaikan pesanan hanya jika semua langkah sudah selesai.

START TRANSACTION;
UPDATE transfer_steps SET status = 'done', done_at = NOW()
WHERE order_id = 1 AND step_no = 4 AND status = 'doing';
UPDATE orders SET status = 'completed'
WHERE id = 1 AND status = 'documents_pending'
  AND NOT EXISTS (SELECT 1 FROM transfer_steps WHERE order_id = 1 AND status <> 'done');
UPDATE car_listings SET status = 'sold' WHERE id = 1 AND status = 'reserved';
COMMIT;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Topik 11: Pembiayaan dan Garansi

Sebagian besar pembeli tidak membayar penuh secara tunai. Kredit bank adalah pengajuan yang terhubung dengan pesanan, dan garansi melindungi pembeli selama beberapa bulan atau kilometer setelah penjualan.

Pengajuan pembiayaan

Pengajuan pembiayaan termasuk dalam sebuah pesanan dan mencatat bank, jumlah, tenor, dan status: diajukan, disetujui, ditolak, atau ditarik. Tenor dibatasi oleh aturan CHECK, misalnya 6 sampai 84 bulan, sehingga kredit yang tidak mungkin tidak bisa dimasukkan.

Garansi dihitung dari tanggal penjualan

Garansi hanya dibuat ketika pesanan selesai. Garansi menyimpan jumlah bulan, batas kilometer, dan tanggal mulai. Pemeriksaan apakah garansi masih berlaku membandingkan tanggal dan kilometer sekaligus, sehingga pembeli mendapatkan jawaban yang benar.

Menampilkan penawaran dengan jelas

Halaman pesanan menampilkan cicilan bulanan sebagai perkiraan dan ketentuan garansi dalam bahasa yang sederhana. Informasi yang jelas mengurangi sengketa setelah penjualan, dan itu lebih murah daripada menangani keluhan kemudian.

Contoh 73: Pengajuan pembiayaan

Pembeli mengajukan pinjaman ke bank untuk pembelian.

CREATE TABLE financing_applications (
  id              BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  order_id        BIGINT UNSIGNED NOT NULL,
  bank            VARCHAR(80) NOT NULL,
  amount          DECIMAL(12,2) NOT NULL,
  term_months     SMALLINT UNSIGNED NOT NULL,
  status          ENUM('submitted','approved','declined','withdrawn') NOT NULL DEFAULT 'submitted',
  FOREIGN KEY (order_id) REFERENCES orders(id),
  CHECK (term_months BETWEEN 6 AND 84)
) ENGINE=InnoDB;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 74: Contoh pengajuan

Permintaan pinjaman 60 bulan sebesar 600.000 baht.

INSERT INTO financing_applications (order_id, bank, amount, term_months)
VALUES (1, 'Example Bank', 600000.00, 60);

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 75: Garansi

Satu garansi per pesanan, dengan jumlah bulan dan kilometer.

CREATE TABLE warranties (
  id           BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  order_id     BIGINT UNSIGNED NOT NULL UNIQUE,
  months       SMALLINT UNSIGNED NOT NULL,
  km_limit     INT UNSIGNED NOT NULL,
  starts_on    DATE NOT NULL,
  FOREIGN KEY (order_id) REFERENCES orders(id)
) ENGINE=InnoDB;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 76: Mulai garansi saat penjualan selesai

Buat garansi dari pesanan yang sudah selesai.

INSERT INTO warranties (order_id, months, km_limit, starts_on)
SELECT id, 6, 10000, CURDATE() FROM orders WHERE id = 1 AND status = 'completed';

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 77: Periksa apakah garansi masih berlaku

Bandingkan tanggal dan kilometer dengan batasnya.

SELECT w.id,
  (CURDATE() < DATE_ADD(w.starts_on, INTERVAL w.months MONTH)) AND (48250 + 1500 <= 48250 + w.km_limit) AS still_valid
FROM warranties w WHERE w.order_id = 1;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 78: Mockup: kotak pinjaman dan garansi

Perkiraan cicilan dan ketentuan garansi di halaman pesanan.

<aside class="box">
  <h3>Pay with a loan</h3>
  <p>Example Bank · 60 months</p>
  <p>Monthly payment is an estimate: <strong>฿11,800</strong></p>
  <p class="status">Status: Submitted</p>
  <h3>Warranty</h3>
  <p>6 months or 10,000 km, whichever comes first</p>
</aside>

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Topik 12: Ulasan, Laporan, dan Kepercayaan

Pasar hanya berfungsi jika orang mempercayainya. Ulasan datang setelah penjualan selesai, laporan memungkinkan pengguna menandai masalah, dan aturan yang menyembunyikan iklan harus jelas dan adil, sehingga satu pengguna yang marah tidak dapat menyembunyikan mobil yang baik.

Ulasan setelah penjualan

Ulasan hanya diizinkan untuk pesanan yang sudah selesai, dan setiap orang boleh mengulas sekali per pesanan. Penilaian dibatasi 1 sampai 5 oleh aturan CHECK. Rata-rata nilai hanya ditampilkan setelah tiga ulasan, sehingga satu ulasan tidak membuat penjual tampak sempurna atau sangat buruk.

Laporan dari beberapa orang

Laporan menjelaskan apa yang salah, misalnya harga palsu atau kilometer yang tidak sesuai. Hitung pelapor yang berbeda sebelum menyembunyikan iklan, sehingga satu orang tidak dapat menyembunyikan mobil sendirian. Iklan yang disembunyikan masuk ke antrean tinjauan staf.

Mockup tindakan ulasan dan laporan

Formulir ulasan meminta bintang dan komentar, dan tautan laporan terlihat tetapi tidak mengganggu. Desain yang baik membuat pelaporan mudah dan memposting ulasan secara tidak sengaja sulit.

Contoh 79: Ulasan setelah penjualan selesai

Satu ulasan per orang per pesanan, nilai 1 sampai 5.

CREATE TABLE reviews (
  id           BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  order_id     BIGINT UNSIGNED NOT NULL,
  reviewer_id  BIGINT UNSIGNED NOT NULL,
  reviewee_id  BIGINT UNSIGNED NOT NULL,
  rating       TINYINT UNSIGNED NOT NULL,
  comment      VARCHAR(1000) NULL,
  created_at   TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uq_review (order_id, reviewer_id),
  FOREIGN KEY (order_id) REFERENCES orders(id),
  CHECK (rating BETWEEN 1 AND 5)
) ENGINE=InnoDB;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 80: Tambahkan ulasan untuk pesanan selesai

Hanya pesanan yang sudah selesai yang dapat diulas.

INSERT INTO reviews (order_id, reviewer_id, reviewee_id, rating, comment)
SELECT o.id, o.buyer_id, o.seller_id, 5, 'Car as described, smooth transfer.'
FROM orders o WHERE o.id = 1 AND o.status = 'completed';

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 81: Rata-rata nilai penjual

Tampilkan nilai penjual hanya setelah tiga ulasan.

SELECT r.reviewee_id, ROUND(AVG(r.rating), 2) AS avg_rating, COUNT(*) AS reviews
FROM reviews r
GROUP BY r.reviewee_id
HAVING COUNT(*) >= 3;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 82: Laporan daftar

Pembeli dan penjual dapat melaporkan daftar yang mencurigakan.

CREATE TABLE listing_reports (
  id           BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  listing_id   BIGINT UNSIGNED NOT NULL,
  reporter_id  BIGINT UNSIGNED NOT NULL,
  reason       ENUM('fake_price','stolen','wrong_mileage','duplicate','other') NOT NULL,
  status       ENUM('open','investigating','closed') NOT NULL DEFAULT 'open',
  created_at   TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (listing_id) REFERENCES car_listings(id),
  FOREIGN KEY (reporter_id) REFERENCES users(id)
) ENGINE=InnoDB;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 83: Sembunyikan daftar setelah beberapa laporan

Hitung pelapor yang berbeda sebelum mengambil tindakan.

SELECT listing_id, COUNT(DISTINCT reporter_id) AS reporters
FROM listing_reports
WHERE status = 'open'
GROUP BY listing_id
HAVING COUNT(DISTINCT reporter_id) >= 3;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 84: Mockup: formulir ulasan dan tombol lapor

Peringkat bintang, kotak komentar, dan tautan laporan.

<form class="review">
  <h3>Rate your purchase</h3>
  <div class="stars" role="radiogroup" aria-label="Rating">
    <label><input type="radio" name="rating" value="1"> 1</label>
    <label><input type="radio" name="rating" value="5" checked> 5</label>
  </div>
  <textarea maxlength="1000" placeholder="How was the car and the transfer?"></textarea>
  <button>Post review</button>
  <button type="button" class="link">Report this listing</button>
</form>

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Topik 13: Transaksi dalam Praktik: Kondisi Balapan

Dua pembeli dapat menekan tombol Terima pada saat yang sama. Tanpa kehati-hatian, kedua permintaan membaca bahwa mobil masih aktif, dan keduanya menjualnya. Topik ini menunjukkan bagaimana transaksi, kunci baris, urutan kunci, dan kunci idempotensi mencegah hal itu dan kegagalan lain dalam sistem nyata.

Apa yang terjadi tanpa kunci

Sesi A membaca mobil sebagai aktif. Sesi B membaca status yang sama. Keduanya memperbaruinya menjadi dipesan, dan keduanya membuat pesanan. Mobil terjual dua kali. Kunci pada baris mobil, diambil dengan SELECT FOR UPDATE, membuat sesi kedua menunggu sampai yang pertama commit, lalu sesi kedua melihat status dipesan dan berhenti.

Periksa jumlah baris yang berubah

UPDATE dengan WHERE status = active mengubah satu baris untuk pemenang dan nol baris untuk yang kalah. Aplikasi membaca jumlah baris yang terpengaruh dan mengembalikan 409 Conflict kepada yang kalah, sehingga pembeli melihat pesan yang jelas, bukan keberhasilan palsu.

Deadlock, rollback, dan percobaan ulang

Dua transaksi yang mengunci baris yang sama dengan urutan berbeda dapat mengalami deadlock, dan MySQL akan me-rollback salah satunya. Kunci mobil lebih dulu, lalu penawaran, lalu pesanan, di setiap jalur kode. Ketika sesuatu gagal di tengah jalan, ROLLBACK membatalkan setiap perubahan transaksi itu, dan pemanggil dapat mencoba lagi dengan aman menggunakan kunci idempotensi yang sama.

Contoh 85: Dua pembeli menerima bersamaan

Kunci baris agar hanya satu transaksi yang menang.

-- Session A
START TRANSACTION;
SELECT status FROM car_listings WHERE id = 1 FOR UPDATE;
-- Session B waits here until A commits, then reads 'reserved' and stops.
UPDATE car_listings SET status = 'reserved' WHERE id = 1 AND status = 'active';
COMMIT;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 86: Periksa jumlah baris yang diperbarui

Hitungan 1 berarti permintaan ini menang; 0 berarti kalah.

UPDATE car_listings SET status = 'reserved'
WHERE id = 1 AND status = 'active';
-- Application code: if ROW_COUNT() = 0, return 409 Conflict to the second buyer.
SELECT ROW_COUNT() AS won;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 87: Kunci dengan urutan tetap

Setiap jalur kode mengunci mobil sebelum tawaran untuk menghindari deadlock.

-- Every code path locks in this order: car_listings, then offers, then orders
START TRANSACTION;
SELECT id FROM car_listings WHERE id = 1 FOR UPDATE;
SELECT id FROM offers WHERE listing_id = 1 FOR UPDATE;
COMMIT;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 88: Rollback saat mobil sudah diambil

Batalkan pesanan jika pemesanan gagal di tengah jalan.

START TRANSACTION;
UPDATE car_listings SET status = 'reserved' WHERE id = 2 AND status = 'active';
-- If the car was not reserved, nothing else should happen.
-- Application code: when ROW_COUNT() = 0, run ROLLBACK instead of inserting the order.
ROLLBACK;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 89: Kunci idempotensi

Permintaan berulang dengan kunci yang sama mengembalikan hasil pertama.

CREATE TABLE idempotency_keys (
  key_value   CHAR(36) PRIMARY KEY,
  user_id     BIGINT UNSIGNED NOT NULL,
  response    JSON NULL,
  created_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB;
INSERT IGNORE INTO idempotency_keys (key_value, user_id)
VALUES ('5f3c2a10-0000-4000-8000-000000000001', 2);
-- ROW_COUNT() = 0 means the key already exists: return the stored response.

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 90: Log audit

Simpan siapa mengubah apa, dengan nilai lama dan nilai baru.

CREATE TABLE audit_log (
  id           BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  actor_id     BIGINT UNSIGNED NULL,
  action       VARCHAR(60) NOT NULL,
  entity       VARCHAR(40) NOT NULL,
  entity_id    BIGINT UNSIGNED NOT NULL,
  detail       JSON NULL,
  created_at   TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_audit_entity (entity, entity_id, created_at)
) ENGINE=InnoDB;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Topik 14: Pelaporan dan Analitik

Bisnis membutuhkan angka: penjualan per hari, nilai stok per dealer, dan corong dari pemasangan iklan sampai penjualan. Laporan membaca tabel yang sama, jadi harus memakai status yang benar dan tidak pernah menghitung pesanan yang dibatalkan sebagai pendapatan.

Penjualan harian dari pesanan selesai

Hitung hanya pesanan yang selesai sebagai pendapatan. Kelompokkan menurut hari pesanan dibuat, atau hari pesanan diselesaikan jika akuntansi membutuhkannya. Jelaskan pilihan itu di laporan, sehingga dua orang yang membaca angka yang sama sepakat tentang maknanya.

Tampilan untuk dealer

Sebuah view memberi dealer jumlah stok, nilai stok, dan rata-rata hari di pasar dalam satu tempat. View menyembunyikan join, sehingga aplikasi mengkueri view seperti tabel, dan aturannya tetap dalam satu definisi.

Corong konversi dan dasbor

Hitung mobil yang diiklankan, mobil yang menerima setidaknya satu penawaran, dan pesanan yang selesai. Dasbor menampilkan angka kunci di bagian atas dan tabel mobil aktif di bawah, sehingga penjual dapat melihat apa yang perlu perhatian lebih dulu.

Contoh 91: Laporan penjualan harian

Nilai kotor pesanan selesai per hari.

SELECT DATE(o.created_at) AS day,
  COUNT(*) AS orders,
  SUM(o.final_price) AS gross_value
FROM orders o
WHERE o.status = 'completed'
GROUP BY DATE(o.created_at)
ORDER BY day DESC;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 92: Tampilan stok dealer

Jumlah mobil, nilai stok, dan hari dipajang untuk setiap dealer.

CREATE VIEW v_dealer_stock AS
SELECT d.id AS dealer_id,
  COUNT(l.id) AS cars,
  SUM(l.asking_price) AS stock_value,
  AVG(DATEDIFF(NOW(), l.created_at)) AS avg_days_listed
FROM dealers d
JOIN car_listings l ON l.seller_id = d.user_id
WHERE l.status = 'active'
GROUP BY d.id;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 93: Corong konversi

Hitung mobil yang didaftarkan, mobil yang menerima tawaran, dan pesanan selesai.

SELECT
  (SELECT COUNT(*) FROM car_listings WHERE status IN ('active','reserved','sold')) AS listed,
  (SELECT COUNT(DISTINCT listing_id) FROM offers) AS got_offers,
  (SELECT COUNT(*) FROM orders WHERE status = 'completed') AS completed;

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 94: Mockup: dasbor penjual

Angka utama dan tabel mobil yang aktif.

<section class="dashboard">
  <div class="kpi"><span>Active cars</span><strong>12</strong></div>
  <div class="kpi"><span>Open offers</span><strong>3</strong></div>
  <div class="kpi"><span>Sold this month</span><strong>2</strong></div>
  <table>
    <tr><th>Car</th><th>Price</th><th>Offers</th><th>Days listed</th></tr>
    <tr><td>Camry 2019</td><td>฿765,000</td><td>2</td><td>14</td></tr>
  </table>
</section>

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Topik 15: Produksi: API, Keamanan, Cadangan, dan Rilis

Basis data yang berjalan di laptop belum berarti pasar yang siap. Produksi membutuhkan kontrak API yang bersih, pengguna basis data dengan hak minimum, cadangan yang bisa dipulihkan, perubahan skema yang aman, dan daftar periksa rilis yang menghentikan rilis buruk sebelum sampai ke pembeli.

Kontrak API dan idempotensi

Kembalikan iklan dalam bentuk JSON yang stabil dengan kursor untuk halaman berikutnya. Wajibkan header Idempotency-Key untuk penawaran dan pesanan, sehingga percobaan ulang setelah timeout tidak membuat pesanan kedua. Dokumentasikan nilai status, karena aplikasi seluler dan aplikasi web harus membacanya dengan cara yang sama.

Hak minimum, cadangan, dan migrasi

Pengguna aplikasi boleh membaca, menyisipkan, dan memperbarui basis data mobil, tetapi tidak boleh menghapus pembayaran atau catatan audit. Cadangkan dengan dump satu transaksi agar penulisan tidak terblokir, dan uji pemulihannya di mesin lain. Ubah skema dalam langkah kecil: tambahkan kolom yang boleh NULL, isi bertahap per batch, lalu perketat aturannya di rilis berikutnya.

Daftar periksa rilis

Sebelum setiap rilis, jalankan pemeriksaan bisnis: tidak ada mobil aktif tanpa inspeksi lulus, dan tidak ada pesanan yang jumlah bersih yang dibayarnya melebihi harga akhir. Jika ada pemeriksaan yang gagal, skrip rilis berhenti, sehingga aturan yang rusak tidak pernah sampai ke pembeli.

Contoh 95: API daftar publik

Bentuk JSON halaman daftar dengan kursor untuk halaman berikutnya.

{
  "method": "GET",
  "path": "/api/v1/listings?province=Chonburi&max_price=900000&page_size=20",
  "response": {
    "items": [
      { "id": 1, "make": "Toyota", "model": "Camry", "trim": "G 2.5", "year": 2019, "mileage_km": 48250, "asking_price": 765000, "province": "Chonburi" }
    ],
    "next_cursor": "eyJpZCI6MX0"
  }
}

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 96: Ajukan tawaran lewat API

Kirim kunci idempotensi agar percobaan ulang aman.

curl -X POST https://api.example.com/api/v1/listings/1/offers \
  -H "Authorization: Bearer <token>" \
  -H "Idempotency-Key: 5f3c2a10-0000-4000-8000-000000000002" \
  -H "Content-Type: application/json" \
  -d '{"amount": 720000}'

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 97: Hak akses minimum untuk aplikasi

Pengguna aplikasi tidak dapat menghapus pembayaran atau catatan audit.

CREATE USER IF NOT EXISTS 'carmarket_app'@'%' IDENTIFIED BY '<strong-password>';
GRANT SELECT, INSERT, UPDATE ON carmarket.* TO 'carmarket_app'@'%';
-- No DELETE on orders, payments or audit_log: those are kept for legal reasons.
REVOKE DELETE ON carmarket.payments FROM 'carmarket_app'@'%';

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 98: Cadangkan tanpa memblokir penulisan

Gunakan dump satu transaksi dan uji pemulihannya.

mysqldump --single-transaction --routines --triggers carmarket \
  | gzip > carmarket-$(date +%F).sql.gz
# Restore test on another machine:
gunzip -c carmarket-2026-10-30.sql.gz | mysql carmarket_restore_test

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 99: Perubahan skema yang aman

Tambahkan kolom yang boleh NULL, isi bertahap, lalu perketat nanti.

-- Step 1: add the column as nullable (old code keeps working)
ALTER TABLE car_listings ADD COLUMN accident_free TINYINT(1) NULL;
-- Step 2: backfill in batches, not in one huge UPDATE
UPDATE car_listings SET accident_free = 1 WHERE id BETWEEN 1 AND 1000 AND accident_free IS NULL;
-- Step 3 (later release): set NOT NULL after every row has a value.

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Contoh 100: Daftar periksa rilis aturan bisnis

Hentikan rilis saat aturan seperti total bersih terlanggar.

#!/usr/bin/env bash
set -euo pipefail
# 1. No active listing without a passing inspection
mysql -N carmarket -e "SELECT COUNT(*) FROM car_listings l WHERE l.status='active' AND NOT EXISTS (SELECT 1 FROM inspections i WHERE i.listing_id=l.id AND i.overall_score>=3)" | grep -q '^0$'
# 2. Net paid never exceeds the final price
mysql -N carmarket -e "SELECT COUNT(*) FROM orders o JOIN (SELECT order_id, SUM(amount) s FROM payments WHERE status='succeeded' AND kind IN ('deposit','balance') GROUP BY order_id) p ON p.order_id=o.id WHERE p.s > o.final_price" | grep -q '^0$'
echo "business checks passed"

*Contoh ini tidak dijalankan di sini karena MySQL tidak terpasang di komputer yang menyiapkan artikel ini.*

Lab: Membangun dan Menguji Basis Data Pasar Mobil

Satukan artikel ini di basis data uji: buat tabel secara berurutan, masukkan mobil dan pengguna contoh, jalankan penawaran melalui penerimaan dan alur pesanan, simulasikan dua pembeli sekaligus, dan akhiri dengan daftar periksa rilis.

1. Buat basis data carmarket dengan utf8mb4, lalu buat tabel users, dealers, car_makes, car_models, dan car_trims dalam urutan itu.

2. Masukkan pengguna contoh, satu dealer, merek dan model, serta satu varian, lalu periksa jumlah baris dengan SELECT COUNT(*).

3. Buat car_listings, car_images, inspections, dan inspection_items, lalu masukkan satu iklan dengan foto dan inspeksi yang lulus.

4. Terbitkan iklan hanya melalui aturan yang mensyaratkan inspeksi lulus, dan pastikan iklan tanpa inspeksi tetap menunggu tinjauan.

5. Buat offers dan orders, terima satu penawaran dalam transaksi, lalu periksa riwayat status pesanan.

6. Buka dua sesi MySQL, kunci mobil yang sama di keduanya, dan tunjukkan bahwa hanya satu pemesanan yang berhasil.

7. Jalankan daftar periksa rilis dari contoh 100 pada data uji, dan perbaiki aturan yang gagal sebelum melanjutkan.

Langkah berikutnya

Setelah lab, baca panduan desain basis data untuk tumpukan teknologi Anda, tambahkan layar aplikasi seluler dari mockup, dan uji setiap transaksi dengan dua sesi sekaligus sebelum peluncuran. Simpan skrip di repositori dan jalankan ulang setelah setiap perubahan skema, karena aturan pasar akan bertambah seiring pertumbuhan bisnis.

Ringkasan

Pasar mobil bekas adalah sekumpulan aturan yang harus dijaga basis data: satu penjualan per mobil, inspeksi sebelum tayang, uang muka yang ditahan sampai balik nama selesai, dan buku besar yang selalu seimbang. Anda telah belajar menulis aturan terlebih dahulu, memodelkan pengguna, dealer, dan katalog mobil, menayangkan iklan dengan foto dan inspeksi, mencari dengan indeks dan paginasi keyset, menangani penawaran dan pesanan dengan alur status yang jelas, mencatat pembayaran sebagai buku besar, melacak setiap langkah balik nama, menambahkan pembiayaan dan garansi, membangun kepercayaan dengan ulasan dan laporan, menangani kondisi balapan dengan kunci dan idempotensi, membuat laporan bisnis, dan merilis dengan aman.