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.
| Kebutuhan | Keputusan pada schema |
|---|---|
| Email pelanggan unik | email TEXT NOT NULL UNIQUE |
| SKU produk unik | sku TEXT NOT NULL UNIQUE |
| Uang eksak | harga INTEGER dalam satuan sen |
| Harga selalu positif | CHECK (harga > 0) |
| Stok tidak negatif | stok INTEGER NOT NULL DEFAULT 0 CHECK (stok >= 0) |
| Jejak perubahan stok | tabel 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
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:
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
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:
Rp750.000,00
Rp499.500,00
Rp59,95
Rp1.999.500,00Helper 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:
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:
id INTEGER PRIMARY KEY AUTOINCREMENT: identitas baris yang terisi otomatis;AUTOINCREMENTmemastikan id yang sudah terpakai tidak pernah dipakai ulang.email ... UNIQUEdansku ... UNIQUE: dua constraint ini juga otomatis membuat index, sehingga pencarian berdasarkan email atau SKU cepat tanpaCREATE INDEXtambahan.harga INTEGER NOT NULL CHECK (harga > 0): harga disimpan dalam sen dan tidak boleh nol — produk gratis bukan tanggung jawab kolom harga.stok INTEGER NOT NULL DEFAULT 0 CHECK (stok >= 0): inilah penjaga stok. Berapa pun kode aplikasi keliru menghitung,UPDATEyang membuat stok negatif akan ditolak database dengan galatCHECK constraint failed.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:
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
$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:
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 failedKarena 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:
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
$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:
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: 0Objek 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
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:
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
$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:
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=6Keempat 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:
sistem-pemesanan/
└── bagian-1-model-pelanggan-dan-produk/
├── schema.sql
└── uji-skema.phpSimpan salah satu snippet pengujian di atas sebagai uji-skema.php di samping schema.sql, lalu jalankan dari dalam folder tersebut:
php uji-skema.phpPHP 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.sqlberdiri sebagai satu-satunya sumber kebenaran struktur data: lima tabel (customers,products,orders,order_items,stock_movements) plus empat index eksplisit dan index otomatis dariUNIQUE. - Konvensi uang ditetapkan sejak awal: semua kolom harga bertipe
INTEGERdalam satuan sen, dengan helperrupiah()murni integer untuk menampilkaninya. - Penjaga bisnis hidup di database:
UNIQUEemail dan SKU,CHECK harga > 0,CHECK stok >= 0,CHECKstatus 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-- 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.