Sistem inventaris adalah proyek yang kalah atau menang di tingkat datanya, bukan di tampilannya. Kalau satu barang tercatat keluar dua kali, atau stok menunjukkan angka negatif, kepercayaan pengguna runtuh lebih cepat daripada bug tampilan mana pun. Karena itu kita mulai dari satu berkas schema.sql di atas SQLite: tiga tabel dengan relasi yang jelas, constraint yang menolak data mustahil sejak awal, dan konsep inti proyek ini — tabel stock_movements yang bekerja seperti buku besar (ledger): setiap perubahan stok adalah fakta baru yang dicatat, bukan angka lama yang ditimpa. Pada bagian ini seluruh aturan tersebut kita buktikan dari PHP, bukan sekadar dipercaya.
Dari Kebutuhan ke Struktur Data #
Sebelum menulis SQL, kita daftarkan dulu kebutuhan datanya secara eksplisit. Sistem inventaris yang akan kita bangun setidaknya harus memenuhi hal berikut:
- Produk memiliki SKU sebagai pengenal unik, nama, kategori, dan harga satuan.
- Setiap produk selalu menunjuk satu kategori yang sah; kategori bisa kosong.
- Stok barang tidak boleh negatif dan harga tidak boleh lebih kecil dari nol.
- Setiap perubahan stok harus tercatat: barang masuk atau keluar, berapa jumlahnya, kenapa, dan kapan.
- Stok saat ini (
stok_sekarang) harus selalu bisa diperiksa kebenarannya terhadap catatan masuk-keluar.
Setiap kebutuhan tersebut diterjemahkan menjadi satu keputusan konkret pada schema, bukan dibiarkan sebagai niat baik di kode aplikasi:
| Kebutuhan | Keputusan pada schema |
|---|---|
| SKU unik per produk | sku TEXT NOT NULL UNIQUE |
| Kategori sah dan boleh kosong | kategori_id REFERENCES categories(id) + tabel terpisah |
| Stok tidak negatif | stok_sekarang INTEGER CHECK (stok_sekarang >= 0) |
| Harga tidak negatif | harga_satuan INTEGER CHECK (harga_satuan >= 0) |
| Setiap perubahan tercatat | tabel stock_movements terpisah, append-only |
| Jenis dan jumlah movement tervalidasi | CHECK (jenis IN ('masuk','keluar')), CHECK (jumlah > 0) |
Pemetaan ini menjadi kontrak antara database dan seluruh bagian proyek berikutnya. Ketika Bagian 2 mulai menulis repository, method di sana cukup percaya bahwa SKU duplikat tidak mungkin lolos; ketika Bagian 3 menulis buku besar, jenis movement yang tidak dikenal sudah ditolak di pintu masuk.
Tabel categories dan products #
Dua tabel pertama menyimpan data master. Berikut definisinya sebagaimana ditulis pada berkas schema.sql:
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nama TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sku TEXT NOT NULL UNIQUE,
nama TEXT NOT NULL,
kategori_id INTEGER NOT NULL REFERENCES categories(id),
harga_satuan INTEGER NOT NULL CHECK (harga_satuan >= 0),
stok_sekarang INTEGER NOT NULL DEFAULT 0 CHECK (stok_sekarang >= 0)
);Mari kita bedah peran setiap bagian penting dari deklarasi products:
sku TEXT NOT NULL UNIQUE: SKU (stock keeping unit) adalah kode unik gudang. ConstraintUNIQUEmenolak dua produk dengan SKU sama sekaligus otomatis membuat index sehingga pencarian SKU cepat — fondasi bagi methodcariSku()di Bagian 2.kategori_id INTEGER NOT NULL REFERENCES categories(id): foreign key memaksa setiap produk menunjuk kategori yang benar-benar ada. Kolom iniNOT NULLkarena produk tanpa kategori tidak bermakna dalam laporan nantinya.harga_satuan INTEGER NOT NULL CHECK (harga_satuan >= 0): harga disimpan sebagai integer rupiah (bukan float) agar bebas galat pembulatan.CHECKmenolak harga negatif sejak awal.stok_sekarang INTEGER NOT NULL DEFAULT 0 CHECK (stok_sekarang >= 0): kolom ini adalah snapshot stok saat ini. Nilai0membuat produk baru selalu lahir tanpa stok — menambah stok adalah operasi inventaris yang wajib meninggalkan catatan, bukan sekadar isi kolom saat INSERT.
Nama kategori sendiri diberi UNIQUE agar dua kategori bernama sama tidak bisa terbentuk — aturan yang juga dipakai teknik seeding idempoten di bawah.
Tabel stock_movements sebagai Buku Besar #
Inilah keputusan paling penting pada proyek ini. Ada dua cara menyimpan stok: menyimpan hanya angka terkini (dan kehilangan jejak), atau menyimpan angka terkini plus setiap kejadian yang membentuknya. Kita memilih yang kedua. Tabel stock_movements bekerja seperti buku besar akuntan: baris baru ditambahkan untuk setiap kejadian, dan baris lama tidak pernah diubah maupun dihapus (append-only).
CREATE TABLE IF NOT EXISTS stock_movements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id INTEGER NOT NULL REFERENCES products(id),
jenis TEXT NOT NULL CHECK (jenis IN ('masuk', 'keluar')),
jumlah INTEGER NOT NULL CHECK (jumlah > 0),
keterangan TEXT,
dibuat_pada TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);Empat keputusan pada tabel ini perlu digarisbawahi:
jenis ... CHECK (jenis IN ('masuk', 'keluar')): hanya dua arah pergerakan yang sah. Typo seperti'kelar'atau'Masuk'(huruf besar) langsung ditolak database.jumlah ... CHECK (jumlah > 0): movement nol atau negatif tidak bermakna — menambah nol barang bukanlah kejadian inventaris.keterangan TEXTsengaja bolehNULL: tidak semua kejadian punya nomor referensi, dan kolom ini murni deskriptif.product_id ... REFERENCES products(id): foreign key ini punya efek samping yang berguna — produk yang masih memiliki riwayat movement tidak bisa dihapus, sehingga buku besar tidak pernah menunjuk baris yang lenyap.
Dengan dua sumber ini, stok_sekarang pada tabel products menjadi angka yang bisa diaudit: jumlah masuk dikurangi jumlah keluar harus selalu sama dengannya. Bagian 3 akan menghitung rekonsiliasi ini di kode, dan Bagian 4 menjaga keduanya tetap seirama lewat transaksi.
Indeks untuk Pola Query #
Index mempercepat pencarian dengan biaya ruang dan sedikit perlambatan penulisan, jadi kita hanya membuat index untuk kolom yang benar-benar dipakai pola query aplikasi:
CREATE INDEX IF NOT EXISTS idx_products_nama ON products(nama);
CREATE INDEX IF NOT EXISTS idx_movements_product ON stock_movements(product_id);| Index | Sumber | Manfaat |
|---|---|---|
idx_products_nama | CREATE INDEX eksplisit | mempercepat urut dan cari nama produk |
idx_movements_product | CREATE INDEX eksplisit | mempercepat pengambilan riwayat satu produk |
sqlite_autoindex_products_1 | UNIQUE pada kolom sku | mempercepat cek duplikat dan cariSku() |
sqlite_autoindex_categories_1 | UNIQUE pada kolom nama | mempercepat cek duplikat kategori |
Kolom dibuat_pada sengaja tidak diberi index pada tahap ini: riwayat kita urut berdasarkan id yang naik bersamaan dengan waktu, dan index yang belum dipakai pola query hanya menambah biaya penulisan.
Data Awal dan Jaminan Idempotensi #
Database kosong memaksa setiap demo menyusun data dari nol. Akhir schema.sql karena itu menyisipkan empat kategori dan enam produk toko kelontong:
INSERT OR IGNORE INTO categories (nama) VALUES
('Minuman'),
('Makanan Ringan'),
('Alat Tulis'),
('Peralatan Kantor');
INSERT OR IGNORE INTO products (sku, nama, kategori_id, harga_satuan, stok_sekarang) VALUES
('KOPI-001', 'Kopi Hitam Sachet 20g', 1, 2500, 100),
('TEH-002', 'Teh Celup Melati', 1, 5000, 60),
('BISK-003', 'Biskuit Cokelat 120g', 2, 8500, 45),
('KERI-004', 'Keripik Kentang 75g', 2, 9000, 80),
('PENS-005', 'Pensil HB', 3, 3000, 200),
('BUKU-006', 'Buku Tulis 58 Halaman', 3, 4500, 35);INSERT OR IGNORE bekerja sama dengan kolom UNIQUE: bila SKU atau nama kategori sudah ada, SQLite melewati baris itu diam-diam, sehingga schema.sql aman dijalankan berulang. Kategori Peralatan Kantor sengaja dibiarkan kosong — nanti kita pakai untuk membuktikan LEFT JOIN pada Bagian 2.
Stok pada data awal tidak boleh muncul dari udara: buku besar harus bisa menjelaskannya. Karena itu setiap produk juga mendapat movement pembuka, dan tiga produk di antaranya sengaja diberi riwayat masuk-keluar agar lebih hidup:
WITH data_awal(product_id, jenis, jumlah, keterangan, dibuat_pada) AS (
VALUES
(1, 'masuk', 100, 'Pembelian awal PO-2601-001', '2026-01-05 08:30:00'),
(2, 'masuk', 60, 'Pembelian awal PO-2601-001', '2026-01-05 08:35:00'),
(3, 'masuk', 50, 'Pembelian awal PO-2601-002', '2026-01-06 09:00:00'),
(3, 'keluar', 5, 'Penjualan INV-2601-101', '2026-01-08 10:12:00'),
(4, 'masuk', 100, 'Pembelian awal PO-2601-003', '2026-01-06 09:05:00'),
(4, 'keluar', 20, 'Penjualan INV-2601-115', '2026-01-09 14:40:00'),
(5, 'masuk', 250, 'Pembelian awal PO-2601-004', '2026-01-07 11:20:00'),
(5, 'keluar', 50, 'Penjualan INV-2601-120', '2026-01-10 16:05:00'),
(6, 'masuk', 35, 'Pembelian awal PO-2601-004', '2026-01-07 11:25:00')
)
INSERT INTO stock_movements (product_id, jenis, jumlah, keterangan, dibuat_pada)
SELECT d.product_id, d.jenis, d.jumlah, d.keterangan, d.dibuat_pada
FROM data_awal AS d
WHERE NOT EXISTS (SELECT 1 FROM stock_movements);Blok WITH ... VALUES mendefinisikan tabel sementara berisi data awal, lalu INSERT ... SELECT menyalinnya ke buku besar. Syarat WHERE NOT EXISTS (SELECT 1 FROM stock_movements) adalah kunci idempotensi: tabel ini tidak punya kolom UNIQUE untuk didedupe (dua movement yang persis sama adalah kejadian yang sah!), jadi seluruh batch hanya dimasukkan bila buku besar masih kosong. Menjalankan schema.sql dua kali pun tidak akan menggandakan catatan. Perhatikan juga BISK-003: masuk 50, keluar 5, stok 45 — matematikanya harus cocok, dan kita buktikan di bagian paling bawah halaman ini.
Menguji Struktur dari PHP #
Skema belum bisa dipercaya sebelum strukturnya terbukti. SQLite menyimpan daftar tabel dan index miliknya pada tabel khusus sqlite_master, posisi kolom bisa dibaca lewat PRAGMA table_info, dan relasi foreign key lewat PRAGMA foreign_key_list. Skrip berikut memuat schema.sql ke database :memory: lalu memeriksa ketiganya:
<?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'));
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";
}
echo "\nForeign key products:\n";
foreach ($pdo->query('PRAGMA foreign_key_list(products)') as $fk) {
echo " {$fk['from']} -> {$fk['table']}.{$fk['to']}\n";
}
echo "\nForeign key stock_movements:\n";
foreach ($pdo->query('PRAGMA foreign_key_list(stock_movements)') as $fk) {
echo " {$fk['from']} -> {$fk['table']}.{$fk['to']}\n";
}Output yang Dihasilkan:
Objek database hasil schema.sql:
index: idx_movements_product
index: idx_products_nama
index: sqlite_autoindex_categories_1
index: sqlite_autoindex_products_1
table: categories
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: -
kategori_id bertipe INTEGER, wajib diisi: 1, primary key: 0, nilai default: -
harga_satuan bertipe INTEGER, wajib diisi: 1, primary key: 0, nilai default: -
stok_sekarang bertipe INTEGER, wajib diisi: 1, primary key: 0, nilai default: 0
Foreign key products:
kategori_id -> categories.id
Foreign key stock_movements:
product_id -> products.idAda delapan objek pada output di atas dan semuanya sah: tiga tabel buatan kita, dua index eksplisit, dua index otomatis dari constraint UNIQUE, dan sqlite_sequence yang dikelola SQLite sendiri untuk mengingat nomor AUTOINCREMENT. Baris id wajib diisi: 0 juga bukan kekeliruan — primary key INTEGER selalu terisi otomatis sehingga tidak perlu NOT NULL eksplisit. Dua daftar foreign key menegaskan relasi yang kita rancang: products.kategori_id menunjuk categories.id, dan stock_movements.product_id menunjuk products.id.
Memeriksa Data Awal #
Berikutnya kita pastikan data awal benar-benar terpasang, termasuk JOIN yang menyambungkan produk dengan nama kategorinya:
<?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'));
echo "Data awal products:\n";
$sql = 'SELECT p.sku, p.nama, c.nama AS kategori, p.harga_satuan, p.stok_sekarang
FROM products p
JOIN categories c ON c.id = p.kategori_id
ORDER BY p.id';
foreach ($pdo->query($sql) as $p) {
printf(
" %-9s %-24s %-15s Rp%-5d stok %d\n",
$p['sku'],
$p['nama'],
$p['kategori'],
$p['harga_satuan'],
$p['stok_sekarang']
);
}
echo "\nData awal stock_movements:\n";
$sql = 'SELECT m.id, p.sku, m.jenis, m.jumlah, m.keterangan, m.dibuat_pada
FROM stock_movements m
JOIN products p ON p.id = m.product_id
ORDER BY m.id';
foreach ($pdo->query($sql) as $m) {
printf(
" #%-2d %-9s %-6s %3d %s\n",
$m['id'],
$m['sku'],
$m['jenis'],
$m['jumlah'],
$m['keterangan']
);
}Output yang Dihasilkan:
Data awal products:
KOPI-001 Kopi Hitam Sachet 20g Minuman Rp2500 stok 100
TEH-002 Teh Celup Melati Minuman Rp5000 stok 60
BISK-003 Biskuit Cokelat 120g Makanan Ringan Rp8500 stok 45
KERI-004 Keripik Kentang 75g Makanan Ringan Rp9000 stok 80
PENS-005 Pensil HB Alat Tulis Rp3000 stok 200
BUKU-006 Buku Tulis 58 Halaman Alat Tulis Rp4500 stok 35
Data awal stock_movements:
#1 KOPI-001 masuk 100 Pembelian awal PO-2601-001
#2 TEH-002 masuk 60 Pembelian awal PO-2601-001
#3 BISK-003 masuk 50 Pembelian awal PO-2601-002
#4 BISK-003 keluar 5 Penjualan INV-2601-101
#5 KERI-004 masuk 100 Pembelian awal PO-2601-003
#6 KERI-004 keluar 20 Penjualan INV-2601-115
#7 PENS-005 masuk 250 Pembelian awal PO-2601-004
#8 PENS-005 keluar 50 Penjualan INV-2601-120
#9 BUKU-006 masuk 35 Pembelian awal PO-2601-004Enam produk tampil lengkap dengan kategorinya masing-masing, dan sembilan movement tercatat dengan waktu yang kita bekukan di schema. file_get_contents() mengambil seluruh isi schema.sql dan PDO::exec() menjalankan seluruh statement di dalamnya sekaligus — tabel, index, dan data awal terpasang dalam satu panggilan. Karena semua demo memakai :memory:, tidak ada berkas database yang tertinggal setelah eksekusi.
Membuktikan Constraint Bekerja #
Aturan yang paling penting adalah yang paling sering dilanggar. Skrip berikut sengaja melanggar empat constraint sekaligus — stok negatif, jumlah nol, jenis asing, dan kategori yang tidak ada — lalu mencoba menghapus produk yang masih punya riwayat:
<?php
function coba(string $label, callable $fn): void
{
try {
$fn();
echo "$label: lolos (tidak ditolak)\n";
} catch (PDOException $e) {
echo "$label [{$e->getCode()}]: {$e->getMessage()}\n";
}
}
echo "== Koneksi dengan PRAGMA foreign_keys = ON ==\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'));
coba('Stok negatif ', fn() => $pdo->prepare('UPDATE products SET stok_sekarang = -5 WHERE sku = ?')->execute(['KOPI-001']));
coba('Movement jumlah 0 ', fn() => $pdo->prepare('INSERT INTO stock_movements (product_id, jenis, jumlah) VALUES (1, ?, ?)')->execute(['masuk', 0]));
coba('Jenis asing ', fn() => $pdo->prepare('INSERT INTO stock_movements (product_id, jenis, jumlah) VALUES (1, ?, 5)')->execute(['rusak']));
coba('Kategori asing ', fn() => $pdo->prepare('INSERT INTO products (sku, nama, kategori_id, harga_satuan) VALUES (?, ?, ?, ?)')->execute(['X-999', 'Produk X', 99, 1000]));
coba('Hapus ber-movement', fn() => $pdo->prepare('DELETE FROM products WHERE sku = ?')->execute(['KOPI-001']));
echo "\n== Koneksi baru tanpa pragma, insert yang sama ==\n";
$pdoLepas = new PDO('sqlite::memory:');
$pdoLepas->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdoLepas->exec(file_get_contents(__DIR__ . '/schema.sql'));
coba('Kategori asing ', fn() => $pdoLepas->prepare('INSERT INTO products (sku, nama, kategori_id, harga_satuan) VALUES (?, ?, ?, ?)')->execute(['X-999', 'Produk X', 99, 1000]));
$yatim = (int) $pdoLepas->query('SELECT COUNT(*) FROM products WHERE kategori_id = 99')->fetchColumn();
echo "Baris yatim yang tertulis diam-diam: $yatim\n";Output yang Dihasilkan:
== Koneksi dengan PRAGMA foreign_keys = ON ==
Stok negatif [23000]: SQLSTATE[23000]: Integrity constraint violation: 19 CHECK constraint failed: stok_sekarang >= 0
Movement jumlah 0 [23000]: SQLSTATE[23000]: Integrity constraint violation: 19 CHECK constraint failed: jumlah > 0
Jenis asing [23000]: SQLSTATE[23000]: Integrity constraint violation: 19 CHECK constraint failed: jenis IN ('masuk', 'keluar')
Kategori asing [23000]: SQLSTATE[23000]: Integrity constraint violation: 19 FOREIGN KEY constraint failed
Hapus ber-movement [23000]: SQLSTATE[23000]: Integrity constraint violation: 19 FOREIGN KEY constraint failed
== Koneksi baru tanpa pragma, insert yang sama ==
Kategori asing : lolos (tidak ditolak)
Baris yatim yang tertulis diam-diam: 1Lima pelanggaran pertama ditolak dengan kode SQLSTATE 23000 — kode standar industri untuk pelanggaran integritas. Bagian kedua output adalah pelajarannya: PRAGMA foreign_keys berlaku per koneksi, bukan per database, dan SQLite menonaktifkannya secara default. Pada koneksi baru tanpa pragma, INSERT dengan kategori 99 lolos dan meninggalkan baris yatim tanpa keluhan apa pun. Karena itu mulai Bagian 2, setiap repository menyalakan pragma ini di constructor — titik yang pasti dilewati setiap kali koneksi dipakai.
Rekonsiliasi Stok dengan Buku Besar #
Pemeriksaan terakhir sekaligus prinsip inti proyek ini: stok_sekarang harus selalu bisa dijelaskan oleh buku besar. Query berikut membandingkan kedua sisi untuk setiap produk:
<?php
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec(file_get_contents(__DIR__ . '/schema.sql'));
echo "Rekonsiliasi kolom stok vs buku besar:\n";
$sql = <<<SQL
SELECT p.sku,
p.stok_sekarang,
COALESCE(SUM(CASE WHEN m.jenis = 'masuk' THEN m.jumlah ELSE -m.jumlah END), 0) AS stok_ledger
FROM products p
LEFT JOIN stock_movements m ON m.product_id = p.id
GROUP BY p.id
ORDER BY p.id
SQL;
foreach ($pdo->query($sql) as $r) {
$status = (int) $r['stok_sekarang'] === (int) $r['stok_ledger'] ? 'konsisten' : 'BEDA';
printf(
" %-9s stok_sekarang=%-4d ledger=%-4d %s\n",
$r['sku'],
$r['stok_sekarang'],
$r['stok_ledger'],
$status
);
}Output yang Dihasilkan:
Rekonsiliasi kolom stok vs buku besar:
KOPI-001 stok_sekarang=100 ledger=100 konsisten
TEH-002 stok_sekarang=60 ledger=60 konsisten
BISK-003 stok_sekarang=45 ledger=45 konsisten
KERI-004 stok_sekarang=80 ledger=80 konsisten
PENS-005 stok_sekarang=200 ledger=200 konsisten
BUKU-006 stok_sekarang=35 ledger=35 konsistenEkspresi CASE WHEN m.jenis = 'masuk' THEN m.jumlah ELSE -m.jumlah END menjumlahkan movement dengan tanda: masuk positif, keluar negatif; COALESCE(..., 0) menjaga produk tanpa movement tetap terhitung sebagai nol, dan LEFT JOIN memastikan produk seperti itu tidak hilang dari hasil. Enam produk tampil konsisten — buku besar dan kolom stok saling cocok sejak menit pertama. Menjaga kecocokan ini saat aplikasi berjalan adalah tugas berat yang kita tangani bertahap: Bagian 3 menyediakan pencatatan movement dengan guard, dan Bagian 4 menyatukan pencatatan serta pembaruan stok dalam satu transaksi database.
Cara Menjalankan Contoh #
Seluruh snippet proyek ini saling terhubung melalui berkas di setiap bagian, jadi susun foldernya sebagai berikut sebelum mencoba:
sistem-inventaris/
└── bagian-1-desain-database/
├── schema.sql
└── uji-skema.phpSimpan snippet mana pun sebagai uji-skema.php di samping schema.sql, lalu jalankan dari dalam folder tersebut:
php uji-skema.phpPHP membaca schema.sql relatif terhadap __DIR__ — folder tempat skrip berada — sehingga perintah di atas bisa dijalankan dari folder mana pun asalkan kedua berkas tetap satu folder. Bagian-bagian berikutnya menambah folder saudara seperti bagian-2-produk-dan-kategori/ dan merujuk schema ini lewat path relatif ../bagian-1-desain-database/schema.sql.
Apa yang Berubah #
Proyek dimulai dari folder kosong dan kini memiliki fondasi penyimpanan yang lengkap:
- Berkas
schema.sqlmenjadi satu-satunya sumber kebenaran struktur data: tiga tabel (categories,products,stock_movements) dengan relasikategori_iddanproduct_id. - Empat
CHECKconstraint aktif: stok dan harga tidak negatif, jenis movement terbatasmasuk/keluar, jumlah movement selalu positif. - Konsep buku besar ditegakkan sejak data awal: setiap stok seed dijelaskan oleh movement pembuka sehingga rekonsiliasi langsung
konsisten. schema.sqlidempoten:IF NOT EXISTSuntuk objek,INSERT OR IGNOREuntuk data master, dan guardNOT EXISTSuntuk buku besar.- Perilaku
PRAGMA foreign_keysyang per-koneksi sudah dibuktikan dan terdokumentasi — penyalakannya pindah ke kode PHP mulai Bagian 2.
Ringkasan #
Desain database ini menempatkan integritas persis di tempat yang paling sulit dilanggar: schema. Kebutuhan diterjemahkan menjadi kolom dan constraint, buku besar append-only menjadi sumber kebenaran historis, dan stok_sekarang menjadi angka yang selalu bisa diaudit. Semua aturan terbukti dari PHP — struktur lewat sqlite_master dan PRAGMA, penolakan lewat lima pelanggaran yang sengaja dilakukan, dan konsistensi lewat rekonsiliasi. Pada Bagian 2 kita naik satu lapis: menulis ProductRepository dan CategoryRepository — class yang memegang seluruh akses data master di atas PDO dengan prepared statements.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
schema.sql
Unduh berkas-- schema.sql - struktur database sistem inventaris.
-- Aman dijalankan berulang: CREATE TABLE dan CREATE INDEX memakai IF NOT EXISTS,
-- data awal kategori/produk memakai INSERT OR IGNORE (didedupe kolom UNIQUE),
-- dan data awal stock_movements hanya dimasukkan bila buku besar masih kosong.
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nama TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sku TEXT NOT NULL UNIQUE,
nama TEXT NOT NULL,
kategori_id INTEGER NOT NULL REFERENCES categories(id),
harga_satuan INTEGER NOT NULL CHECK (harga_satuan >= 0),
stok_sekarang INTEGER NOT NULL DEFAULT 0 CHECK (stok_sekarang >= 0)
);
CREATE TABLE IF NOT EXISTS stock_movements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id INTEGER NOT NULL REFERENCES products(id),
jenis TEXT NOT NULL CHECK (jenis IN ('masuk', 'keluar')),
jumlah INTEGER NOT NULL CHECK (jumlah > 0),
keterangan TEXT,
dibuat_pada TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_products_nama ON products(nama);
CREATE INDEX IF NOT EXISTS idx_movements_product ON stock_movements(product_id);
INSERT OR IGNORE INTO categories (nama) VALUES
('Minuman'),
('Makanan Ringan'),
('Alat Tulis'),
('Peralatan Kantor');
INSERT OR IGNORE INTO products (sku, nama, kategori_id, harga_satuan, stok_sekarang) VALUES
('KOPI-001', 'Kopi Hitam Sachet 20g', 1, 2500, 100),
('TEH-002', 'Teh Celup Melati', 1, 5000, 60),
('BISK-003', 'Biskuit Cokelat 120g', 2, 8500, 45),
('KERI-004', 'Keripik Kentang 75g', 2, 9000, 80),
('PENS-005', 'Pensil HB', 3, 3000, 200),
('BUKU-006', 'Buku Tulis 58 Halaman', 3, 4500, 35);
-- Data awal movement menjelaskan stok setiap produk sehingga buku besar dan
-- kolom stok_sekarang konsisten sejak awal. Tabel ini tidak punya kolom
-- UNIQUE untuk didedupe, maka seluruh batch hanya dijalankan bila tabel
-- masih kosong (NOT EXISTS) - itulah syarat idempotensi blok ini.
WITH data_awal(product_id, jenis, jumlah, keterangan, dibuat_pada) AS (
VALUES
(1, 'masuk', 100, 'Pembelian awal PO-2601-001', '2026-01-05 08:30:00'),
(2, 'masuk', 60, 'Pembelian awal PO-2601-001', '2026-01-05 08:35:00'),
(3, 'masuk', 50, 'Pembelian awal PO-2601-002', '2026-01-06 09:00:00'),
(3, 'keluar', 5, 'Penjualan INV-2601-101', '2026-01-08 10:12:00'),
(4, 'masuk', 100, 'Pembelian awal PO-2601-003', '2026-01-06 09:05:00'),
(4, 'keluar', 20, 'Penjualan INV-2601-115', '2026-01-09 14:40:00'),
(5, 'masuk', 250, 'Pembelian awal PO-2601-004', '2026-01-07 11:20:00'),
(5, 'keluar', 50, 'Penjualan INV-2601-120', '2026-01-10 16:05:00'),
(6, 'masuk', 35, 'Pembelian awal PO-2601-004', '2026-01-07 11:25:00')
)
INSERT INTO stock_movements (product_id, jenis, jumlah, keterangan, dibuat_pada)
SELECT d.product_id, d.jenis, d.jumlah, d.keterangan, d.dibuat_pada
FROM data_awal AS d
WHERE NOT EXISTS (SELECT 1 FROM stock_movements);
-- Catatan: PRAGMA foreign_keys = ON sengaja tidak ditulis di berkas ini.
-- Pragma tersebut berlaku per koneksi, bukan per database, sehingga wajib
-- dieksekusi oleh kode PHP setiap kali membuka koneksi baru (dibuktikan
-- pada halaman Bagian 1 dan dinyalakan otomatis oleh constructor repository
-- mulai Bagian 2).