Sistem pemesanan adalah proyek yang paling cepat menunjukkan cacat desain: satu kolom harga yang salah tipe bisa membuat total order melenceng diam-diam, dan satu kolom stok yang boleh negatif bisa menjual barang yang tidak pernah ada. Karena itu kita mulai bukan dari tampilan, melainkan dari schema.sql — kontrak data yang dijaga database sendiri lewat constraint. Pada bagian ini kita membangun tabel customers dan products, menetapkan konvensi uang sebagai INTEGER sen, memasang penjaga stok, sekaligus menyiapkan tabel orders, order_items, dan stock_movements yang akan diisi oleh bagian-bagian berikutnya agar tidak ada ALTER TABLE di tengah jalan.

Dari Kebutuhan ke Struktur Data #

Kebutuhan data sistem pemesanan kita petakan dulu secara eksplisit sebelum menulis SQL. Setiap kebutuhan harus berubah menjadi satu keputusan konkret pada schema, bukan sekadar niat baik di kode aplikasi:

  • Pelanggan diidentifikasi lewat email yang tidak boleh dipakai dua akun.
  • Produk diidentifikasi lewat SKU yang unik dan mudah dibaca manusia.
  • Harga adalah uang; nilainya harus eksak sampai dua digit terakhir.
  • Stok tidak boleh negatif — menjual 1 unit dari stok 0 adalah bug, bukan fitur.
  • Setiap perubahan stok kelak harus meninggalkan jejak yang bisa diaudit.
KebutuhanKeputusan pada schema
Email pelanggan unikemail TEXT NOT NULL UNIQUE
SKU produk uniksku TEXT NOT NULL UNIQUE
Uang eksakharga INTEGER dalam satuan sen
Harga selalu positifCHECK (harga > 0)
Stok tidak negatifstok INTEGER NOT NULL DEFAULT 0 CHECK (stok >= 0)
Jejak perubahan stoktabel stock_movements

Pemetaan ini menjadi fondasi seluruh proyek. Ketika Bagian 3 menulis logika reservasi stok, kodenya cukup percaya bahwa stok negatif tidak mungkin lolos ke database — baris pertahanan pertama sudah berdiri di dalam schema.

Uang Disimpan sebagai INTEGER Sen #

Harga produk sering terlihat “bulat” sehingga menggoda untuk disimpan sebagai float. Masalahnya, float merepresentasikan angka dalam bentuk biner pecahan, dan banyak nilai desimal tidak bisa direpresentasikan secara eksak — galatnya kecil, tetapi uang dijumlahkan ribuan kali sehari sehingga galat itu menumpuk dan total order meleset:

php
<?php
var_dump(0.1 + 0.2);

$hargaFloat = [19.99, 29.97, 9.99];
$total = 0.0;
foreach ($hargaFloat as $harga) {
    $total += $harga;
}
var_dump($total);
var_dump($total === 59.95);

$totalSen = 1999 + 2997 + 999;
var_dump($totalSen);

Output yang Dihasilkan:

text
float(0.30000000000000004)
float(59.949999999999996)
bool(false)
int(5995)

Tiga harga yang seharusnya berjumlah 59.95 menghasilkan 59.949999999999996, dan perbandingan ketat dengan 59.95 pun false. Bandingkan dengan penjumlahan dalam satuan sen: 1999 + 2997 + 999 menghasilkan 5995 — bilangan bulat eksak tanpa galat sebanyak apa pun jumlahnya. Solusinya sederhana dan dipakai sistem pembayaran sungguhan: simpan uang sebagai INTEGER dalam satuan terkecil, yaitu sen, dengan konversi Rp1 = 100 sen. INTEGER 64-bit milik SQLite dan PHP cukup untuk triliunan rupiah dalam sen.

Karena pengguna membaca rupiah, kita perlu satu helper format yang bekerja murni dengan aritmetika integer — intdiv() untuk bagian rupiah dan % untuk sisa sen, tanpa menyentuh float sama sekali:

php
<?php
function rupiah(int $sen): string
{
    $rupiahUtuh = intdiv($sen, 100);
    $sisaSen = $sen % 100;

    return sprintf('Rp%s,%02d', number_format($rupiahUtuh, 0, ',', '.'), $sisaSen);
}

echo rupiah(75000000), "\n";
echo rupiah(49950000), "\n";
echo rupiah(5995), "\n";
echo rupiah(199950000), "\n";

Output yang Dihasilkan:

text
Rp750.000,00
Rp499.500,00
Rp59,95
Rp1.999.500,00

Helper rupiah() ini akan dipakai berulang kali pada bagian-bagian berikutnya setiap kali angka sen perlu ditampilkan. Perhatikan number_format() di sini hanya memformat bagian rupiah utuh — pembagian intdiv() sudah eksak, jadi tidak ada galat float yang masuk lewat pintu belakang.

Tabel customers dan products #

Kedua tabel inti bagian ini dideklarasikan sebagaimana tertulis pada berkas schema.sql:

sql
CREATE TABLE IF NOT EXISTS customers (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    nama       TEXT    NOT NULL,
    email      TEXT    NOT NULL UNIQUE,
    created_at TEXT    NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS products (
    id    INTEGER PRIMARY KEY AUTOINCREMENT,
    sku   TEXT    NOT NULL UNIQUE,
    nama  TEXT    NOT NULL,
    harga INTEGER NOT NULL CHECK (harga > 0),
    stok  INTEGER NOT NULL DEFAULT 0 CHECK (stok >= 0)
);

CREATE INDEX IF NOT EXISTS idx_products_nama ON products(nama);

Mari kita bedah peran bagian-bagian pentingnya:

  1. id INTEGER PRIMARY KEY AUTOINCREMENT: identitas baris yang terisi otomatis; AUTOINCREMENT memastikan id yang sudah terpakai tidak pernah dipakai ulang.
  2. email ... UNIQUE dan sku ... UNIQUE: dua constraint ini juga otomatis membuat index, sehingga pencarian berdasarkan email atau SKU cepat tanpa CREATE INDEX tambahan.
  3. harga INTEGER NOT NULL CHECK (harga > 0): harga disimpan dalam sen dan tidak boleh nol — produk gratis bukan tanggung jawab kolom harga.
  4. stok INTEGER NOT NULL DEFAULT 0 CHECK (stok >= 0): inilah penjaga stok. Berapa pun kode aplikasi keliru menghitung, UPDATE yang membuat stok negatif akan ditolak database dengan galat CHECK constraint failed.
  5. idx_products_nama: satu-satunya index eksplisit; daftar produk paling sering ditampilkan urut nama.

Tabel untuk Tahap Berikutnya #

Order tidak bisa berdiri dari satu tabel: satu order punya pelaku (customer), status, dan banyak baris item. Karena schema.sql adalah satu-satunya sumber kebenaran struktur, ketiga tabel berikut kita tulis sekalian sekarang — cara ini menghindarkan kita dari ALTER TABLE di tengah proyek:

sql
CREATE TABLE IF NOT EXISTS orders (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    customer_id INTEGER NOT NULL REFERENCES customers(id),
    status      TEXT    NOT NULL DEFAULT 'pending'
                CHECK (status IN ('pending', 'dibayar', 'dikirim', 'selesai', 'batal')),
    created_at  TEXT    NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS order_items (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    order_id   INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
    product_id INTEGER NOT NULL REFERENCES products(id),
    qty        INTEGER NOT NULL CHECK (qty > 0),
    harga      INTEGER NOT NULL CHECK (harga > 0)
);

CREATE TABLE IF NOT EXISTS stock_movements (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    product_id INTEGER NOT NULL REFERENCES products(id),
    order_id   INTEGER REFERENCES orders(id),
    jenis      TEXT    NOT NULL CHECK (jenis IN ('masuk', 'keluar')),
    qty        INTEGER NOT NULL CHECK (qty > 0),
    keterangan TEXT    NOT NULL DEFAULT '',
    created_at TEXT    NOT NULL DEFAULT CURRENT_TIMESTAMP
);

Tiga keputusan pada DDL di atas menjadi jantung bagian-bagian selanjutnya. Kolom harga pada order_items adalah tempat harga disalin saat order dibuat — snapshot yang membuat perubahan harga produk di kemudian hari tidak pernah menyentuh order lama (dibedah tuntas di Bagian 2). Kolom status dibatasi CHECK pada lima nilai resmi, sehingga nilai liar seperti "shiped" tidak mungkin masuk. Terakhir, stock_movements mencatat setiap gerakan masuk/keluar beserta order_id pemicunya — order_id boleh NULL karena restock dan koreksi stok memang terjadi di luar order.

Relasi antartabel di atas dijaga foreign key. Satu keunikan SQLite yang sering menjebak: pemeriksaan foreign key nonaktif secara default pada setiap koneksi. Bandingkan dua koneksi berikut terhadap order_items yang merujuk produk yang tidak ada:

php
<?php
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec(file_get_contents(__DIR__ . '/schema.sql'));

$sql = 'INSERT INTO order_items (order_id, product_id, qty, harga) VALUES (?, ?, ?, ?)';
$pdo->prepare($sql)->execute([1, 999, 1, 100]);
$jumlah = $pdo->query('SELECT COUNT(*) FROM order_items')->fetchColumn();
echo "Tanpa PRAGMA foreign_keys : produk #999 tersimpan diam-diam ($jumlah baris sampah)\n";

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/schema.sql'));
try {
    $pdo->prepare($sql)->execute([1, 999, 1, 100]);
    echo "Dengan PRAGMA foreign_keys: tersimpan?\n";
} catch (PDOException $e) {
    echo "Dengan PRAGMA foreign_keys: ditolak [{$e->getCode()}]\n";
    echo "  {$e->getMessage()}\n";
}

Output yang Dihasilkan:

text
Tanpa PRAGMA foreign_keys : produk #999 tersimpan diam-diam (1 baris sampah)
Dengan PRAGMA foreign_keys: ditolak [23000]
  SQLSTATE[23000]: Integrity constraint violation: 19 FOREIGN KEY constraint failed

Karena pragma berlaku per koneksi, schema.sql sengaja tidak memuatnya — kelas-kelas PHP pada bagian berikutnya yang menyalakannya di constructor, titik yang pasti dilewati setiap kali koneksi dipakai.

Data Awal yang Aman Dijalankan Berulang #

Database kosong memaksa setiap demo menyusun data dari nol, jadi akhir schema.sql menyisipkan lima pelanggan, enam produk, dan gerakan stok awal untuk setiap produk agar jejak audit lengkap sejak awal:

sql
INSERT OR IGNORE INTO customers (nama, email, created_at) VALUES
    ('Budi Santoso',   '[email protected]',  '2026-01-10 09:00:00'),
    ('Siti Nurhaliza', '[email protected]',  '2026-01-11 10:30:00'),
    ('Andi Wijaya',    '[email protected]',  '2026-01-12 14:05:00'),
    ('Dewi Lestari',   '[email protected]',  '2026-01-13 16:45:00'),
    ('Rian Hidayat',   '[email protected]',  '2026-01-14 11:20:00');

INSERT OR IGNORE INTO products (sku, nama, harga, stok) VALUES
    ('KB-100', 'Keyboard Mekanik RGB',     75000000, 12),
    ('MS-200', 'Mouse Wireless Ergonomis', 25000000, 25),
    ('HD-300', 'Headset Stereo Bluetooth', 49950000, 8),
    ('MN-400', 'Monitor IPS 24 Inci',     185000000, 5),
    ('HB-500', 'USB-C Hub 7-in-1',         31500000, 3),
    ('WC-600', 'Webcam Full HD 1080p',     42500000, 15);

INSERT OR IGNORE INTO stock_movements (id, product_id, order_id, jenis, qty, keterangan, created_at) VALUES
    (1, 1, NULL, 'masuk', 12, 'stok awal', '2026-01-10 08:00:00'),
    (2, 2, NULL, 'masuk', 25, 'stok awal', '2026-01-10 08:00:00'),
    (3, 3, NULL, 'masuk', 8,  'stok awal', '2026-01-10 08:00:00'),
    (4, 4, NULL, 'masuk', 5,  'stok awal', '2026-01-10 08:00:00'),
    (5, 5, NULL, 'masuk', 3,  'stok awal', '2026-01-10 08:00:00'),
    (6, 6, NULL, 'masuk', 15, 'stok awal', '2026-01-10 08:00:00');

Tiga keputusan kecil di blok tersebut perlu dijelaskan. Pertama, INSERT OR IGNORE bekerja sama dengan UNIQUE pada email dan SKU sehingga schema.sql aman dijalankan berulang. Kedua, created_at diisi nilai eksplisit yang dibekukan agar setiap demo pada bagian-bagian proyek ini menampilkan hasil yang sama persis. Ketiga, baris stock_movements memakai id eksplisit — begitu schema dijalankan kedua kalinya, INSERT OR IGNORE menabrak primary key yang sama dan barisnya dilewati; tanpa ini, benih gerakan stok akan dobel setiap eksekusi ulang.

Menguji Skema dari PHP #

Skema belum bisa dipercaya sebelum strukturnya terbukti. SQLite menyimpan daftar objeknya pada tabel sqlite_master dan struktur kolom bisa dibaca lewat PRAGMA table_info. Skrip berikut memuat schema.sql ke database :memory: lalu memeriksa keduanya:

php
<?php
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec(file_get_contents(__DIR__ . '/schema.sql'));

echo "Objek database hasil schema.sql:\n";
foreach ($pdo->query('SELECT type, name FROM sqlite_master ORDER BY type, name') as $objek) {
    echo "  {$objek['type']}: {$objek['name']}\n";
}

echo "\nStruktur kolom products:\n";
foreach ($pdo->query('PRAGMA table_info(products)') as $kolom) {
    $default = $kolom['dflt_value'] ?? '-';
    echo "  {$kolom['name']} bertipe {$kolom['type']},"
        . " wajib diisi: {$kolom['notnull']},"
        . " primary key: {$kolom['pk']},"
        . " nilai default: $default\n";
}

Output yang Dihasilkan:

text
Objek database hasil schema.sql:
  index: idx_order_items_order
  index: idx_orders_customer
  index: idx_products_nama
  index: idx_stock_movements_product
  index: sqlite_autoindex_customers_1
  index: sqlite_autoindex_products_1
  table: customers
  table: order_items
  table: orders
  table: products
  table: sqlite_sequence
  table: stock_movements

Struktur kolom products:
  id bertipe INTEGER, wajib diisi: 0, primary key: 1, nilai default: -
  sku bertipe TEXT, wajib diisi: 1, primary key: 0, nilai default: -
  nama bertipe TEXT, wajib diisi: 1, primary key: 0, nilai default: -
  harga bertipe INTEGER, wajib diisi: 1, primary key: 0, nilai default: -
  stok bertipe INTEGER, wajib diisi: 1, primary key: 0, nilai default: 0

Objek sqlite_autoindex_* dan sqlite_sequence dibuat diam-diam oleh SQLite: yang pertama dari constraint UNIQUE, yang terakhir untuk mengingat nomor AUTOINCREMENT. Sekarang kita pastikan data awal terbaca apa adanya, lengkap dengan harga terformat:

php
<?php
function rupiah(int $sen): string
{
    return sprintf('Rp%s,%02d', number_format(intdiv($sen, 100), 0, ',', '.'), $sen % 100);
}

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec(file_get_contents(__DIR__ . '/schema.sql'));

echo "Pelanggan:\n";
foreach ($pdo->query('SELECT id, nama, email FROM customers ORDER BY id') as $c) {
    printf("  #%d %-16s <%s>\n", $c['id'], $c['nama'], $c['email']);
}

echo "\nProduk:\n";
foreach ($pdo->query('SELECT sku, nama, harga, stok FROM products ORDER BY id') as $p) {
    printf("  %-7s %-24s %14s  stok %2d\n", $p['sku'], $p['nama'], rupiah((int) $p['harga']), $p['stok']);
}

echo "\nGerakan stok awal:\n";
foreach ($pdo->query('SELECT id, product_id, jenis, qty, keterangan FROM stock_movements ORDER BY id') as $m) {
    printf("  #%d produk #%d %s %d (%s)\n", $m['id'], $m['product_id'], $m['jenis'], $m['qty'], $m['keterangan']);
}

Output yang Dihasilkan:

text
Pelanggan:
  #1 Budi Santoso     <[email protected]>
  #2 Siti Nurhaliza   <[email protected]>
  #3 Andi Wijaya      <[email protected]>
  #4 Dewi Lestari     <[email protected]>
  #5 Rian Hidayat     <[email protected]>

Produk:
  KB-100  Keyboard Mekanik RGB       Rp750.000,00  stok 12
  MS-200  Mouse Wireless Ergonomis   Rp250.000,00  stok 25
  HD-300  Headset Stereo Bluetooth   Rp499.500,00  stok  8
  MN-400  Monitor IPS 24 Inci      Rp1.850.000,00  stok  5
  HB-500  USB-C Hub 7-in-1           Rp315.000,00  stok  3
  WC-600  Webcam Full HD 1080p       Rp425.000,00  stok 15

Gerakan stok awal:
  #1 produk #1 masuk 12 (stok awal)
  #2 produk #2 masuk 25 (stok awal)
  #3 produk #3 masuk 8 (stok awal)
  #4 produk #4 masuk 5 (stok awal)
  #5 produk #5 masuk 3 (stok awal)
  #6 produk #6 masuk 15 (stok awal)

Perhatikan harga Headset Stereo Bluetooth: 49950000 sen terformat menjadi Rp499.500,00. Harga berakhiran ,00 seperti ini sengaja dipertahankan agar konversi sen terlihat di setiap demo — dan begitu diskon atau pajak masuk di bagian berikutnya, digit sen itu sudah siap kerja.

Membuktikan Constraint Bekerja #

Aturan yang paling penting adalah yang paling sering dilanggar. Skrip berikut menyisipkan satu pelanggan baru lewat prepared statement, lalu dengan sengaja melanggar empat constraint sekaligus — email duplikat, SKU duplikat, harga nol, dan stok negatif:

php
<?php
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/schema.sql'));

$stmt = $pdo->prepare('INSERT INTO customers (nama, email) VALUES (?, ?)');
$stmt->execute(['Tono Sutrisno', '[email protected]']);
echo "Pelanggan baru: #", $pdo->lastInsertId(), " Tono Sutrisno\n\n";

$uji = [
    ['INSERT INTO customers (nama, email) VALUES (?, ?)',
        ['Budi Kedua', '[email protected]'], 'email duplikat'],
    ['INSERT INTO products (sku, nama, harga, stok) VALUES (?, ?, ?, ?)',
        ['KB-100', 'Keyboard Duplikat', 10000000, 1], 'sku duplikat'],
    ['INSERT INTO products (sku, nama, harga, stok) VALUES (?, ?, ?, ?)',
        ['XX-000', 'Produk Gratis', 0, 1], 'harga nol'],
    ['INSERT INTO products (sku, nama, harga, stok) VALUES (?, ?, ?, ?)',
        ['XX-001', 'Produk Minus', 5000000, -1], 'stok negatif'],
];

foreach ($uji as [$sql, $params, $label]) {
    try {
        $pdo->prepare($sql)->execute($params);
        echo "$label: tersimpan??\n";
    } catch (PDOException $e) {
        echo "$label [{$e->getCode()}]: {$e->getMessage()}\n";
    }
}

$pdo->exec(file_get_contents(__DIR__ . '/schema.sql'));
printf("\nSchema dijalankan dua kali: customers=%d, products=%d, gerakan=%d\n",
    $pdo->query('SELECT COUNT(*) FROM customers')->fetchColumn(),
    $pdo->query('SELECT COUNT(*) FROM products')->fetchColumn(),
    $pdo->query('SELECT COUNT(*) FROM stock_movements')->fetchColumn());

Output yang Dihasilkan:

text
Pelanggan baru: #6 Tono Sutrisno

email duplikat [23000]: SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: customers.email
sku duplikat [23000]: SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: products.sku
harga nol [23000]: SQLSTATE[23000]: Integrity constraint violation: 19 CHECK constraint failed: harga > 0
stok negatif [23000]: SQLSTATE[23000]: Integrity constraint violation: 19 CHECK constraint failed: stok >= 0

Schema dijalankan dua kali: customers=6, products=6, gerakan=6

Keempat pelanggaran ditolak dengan kode SQLSTATE 23000 — kode standar untuk pelanggaran integritas constraint — termasuk dua penjaga bisnis kita: CHECK constraint failed: harga > 0 dan CHECK constraint failed: stok >= 0. Baris terakhir membuktikan sifat idempoten: schema dijalankan kedua kalinya dan jumlah baris tetap, termasuk gerakan stok yang dilindungi id eksplisit.

Cara Menjalankan Contoh #

Seluruh snippet pada bagian ini hanya bergantung pada schema.sql di folder yang sama dengan skrip yang Anda buat:

text
sistem-pemesanan/
└── bagian-1-model-pelanggan-dan-produk/
    ├── schema.sql
    └── uji-skema.php

Simpan salah satu snippet pengujian di atas sebagai uji-skema.php di samping schema.sql, lalu jalankan dari dalam folder tersebut:

bash
php uji-skema.php

PHP membaca schema.sql relatif terhadap __DIR__, yaitu folder tempat skrip berada, sehingga perintah di atas bisa dijalankan dari direktori kerja mana pun asalkan kedua berkas tetap satu folder. Seluruh contoh memakai sqlite::memory: yang musnah sendiri saat skrip selesai, jadi tidak ada berkas database yang tertinggal. Bagian-bagian berikutnya akan merujuk schema.sql ini lewat path relatif ../bagian-1-model-pelanggan-dan-produk/schema.sql.

Apa yang Berubah #

Proyek dimulai dari folder kosong dan kini memiliki fondasi penyimpanan yang lengkap:

  • Berkas schema.sql berdiri sebagai satu-satunya sumber kebenaran struktur data: lima tabel (customers, products, orders, order_items, stock_movements) plus empat index eksplisit dan index otomatis dari UNIQUE.
  • Konvensi uang ditetapkan sejak awal: semua kolom harga bertipe INTEGER dalam satuan sen, dengan helper rupiah() murni integer untuk menampilkaninya.
  • Penjaga bisnis hidup di database: UNIQUE email dan SKU, CHECK harga > 0, CHECK stok >= 0, CHECK status order, dan foreign key antartabel.
  • Data awal dibekukan lengkap dengan gerakan stok awal, sehingga setiap demo pada bagian berikutnya reproducible dan schema aman dijalankan berulang.

Ringkasan #

Bagian ini menegakkan aturan persis di tempat yang paling sulit dilanggar: database. Uang disimpan sebagai integer sen supaya aritmetika bebas galat float, stok dijaga CHECK agar tidak pernah negatif, dan relasi antar tabel siap dengan foreign key yang harus dinyalakan per koneksi. Struktur lengkap termasuk tabel order dan gerakan stok sudah berdiri, menunggu kode PHP yang mengisinya. Pada Bagian 2 kita menulis OrderRepository — class yang menciptakan order beserta itemnya dalam satu transaksi dan men-snapshot harga setiap item saat pesanan dibuat.

Referensi #

Berkas Sumber

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

schema.sql

Unduh berkas
sql
-- schema.sql - struktur database proyek sistem pemesanan.
-- Konvensi uang: INTEGER dalam satuan SEN (Rp1 = 100 sen).
-- Aman dijalankan berulang: CREATE ... IF NOT EXISTS + INSERT OR IGNORE.

CREATE TABLE IF NOT EXISTS customers (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    nama       TEXT    NOT NULL,
    email      TEXT    NOT NULL UNIQUE,
    created_at TEXT    NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS products (
    id    INTEGER PRIMARY KEY AUTOINCREMENT,
    sku   TEXT    NOT NULL UNIQUE,
    nama  TEXT    NOT NULL,
    harga INTEGER NOT NULL CHECK (harga > 0),
    stok  INTEGER NOT NULL DEFAULT 0 CHECK (stok >= 0)
);

CREATE INDEX IF NOT EXISTS idx_products_nama ON products(nama);

CREATE TABLE IF NOT EXISTS orders (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    customer_id INTEGER NOT NULL REFERENCES customers(id),
    status      TEXT    NOT NULL DEFAULT 'pending'
                CHECK (status IN ('pending', 'dibayar', 'dikirim', 'selesai', 'batal')),
    created_at  TEXT    NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_orders_customer ON orders(customer_id);

CREATE TABLE IF NOT EXISTS order_items (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    order_id   INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
    product_id INTEGER NOT NULL REFERENCES products(id),
    qty        INTEGER NOT NULL CHECK (qty > 0),
    harga      INTEGER NOT NULL CHECK (harga > 0)
);

CREATE INDEX IF NOT EXISTS idx_order_items_order ON order_items(order_id);

CREATE TABLE IF NOT EXISTS stock_movements (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    product_id INTEGER NOT NULL REFERENCES products(id),
    order_id   INTEGER REFERENCES orders(id),
    jenis      TEXT    NOT NULL CHECK (jenis IN ('masuk', 'keluar')),
    qty        INTEGER NOT NULL CHECK (qty > 0),
    keterangan TEXT    NOT NULL DEFAULT '',
    created_at TEXT    NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_stock_movements_product ON stock_movements(product_id);

INSERT OR IGNORE INTO customers (nama, email, created_at) VALUES
    ('Budi Santoso',   '[email protected]',  '2026-01-10 09:00:00'),
    ('Siti Nurhaliza', '[email protected]',  '2026-01-11 10:30:00'),
    ('Andi Wijaya',    '[email protected]',  '2026-01-12 14:05:00'),
    ('Dewi Lestari',   '[email protected]',  '2026-01-13 16:45:00'),
    ('Rian Hidayat',   '[email protected]',  '2026-01-14 11:20:00');

INSERT OR IGNORE INTO products (sku, nama, harga, stok) VALUES
    ('KB-100', 'Keyboard Mekanik RGB',     75000000, 12),
    ('MS-200', 'Mouse Wireless Ergonomis', 25000000, 25),
    ('HD-300', 'Headset Stereo Bluetooth', 49950000, 8),
    ('MN-400', 'Monitor IPS 24 Inci',     185000000, 5),
    ('HB-500', 'USB-C Hub 7-in-1',         31500000, 3),
    ('WC-600', 'Webcam Full HD 1080p',     42500000, 15);

-- Benih gerakan stok: setiap unit yang ada di rak punya jejak.
-- Id eksplisit membuat blok ini aman dijalankan berulang (INSERT OR IGNORE
-- menabrak primary key saat schema dijalankan kedua kalinya).
INSERT OR IGNORE INTO stock_movements (id, product_id, order_id, jenis, qty, keterangan, created_at) VALUES
    (1, 1, NULL, 'masuk', 12, 'stok awal', '2026-01-10 08:00:00'),
    (2, 2, NULL, 'masuk', 25, 'stok awal', '2026-01-10 08:00:00'),
    (3, 3, NULL, 'masuk', 8,  'stok awal', '2026-01-10 08:00:00'),
    (4, 4, NULL, 'masuk', 5,  'stok awal', '2026-01-10 08:00:00'),
    (5, 5, NULL, 'masuk', 3,  'stok awal', '2026-01-10 08:00:00'),
    (6, 6, NULL, 'masuk', 15, 'stok awal', '2026-01-10 08:00:00');

-- Catatan: PRAGMA foreign_keys sengaja tidak ditulis di berkas ini;
-- pragma berlaku per koneksi, jadi kode PHP-lah yang menyalakannya.