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:

KebutuhanKeputusan pada schema
SKU unik per produksku TEXT NOT NULL UNIQUE
Kategori sah dan boleh kosongkategori_id REFERENCES categories(id) + tabel terpisah
Stok tidak negatifstok_sekarang INTEGER CHECK (stok_sekarang >= 0)
Harga tidak negatifharga_satuan INTEGER CHECK (harga_satuan >= 0)
Setiap perubahan tercatattabel stock_movements terpisah, append-only
Jenis dan jumlah movement tervalidasiCHECK (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:

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:

  1. sku TEXT NOT NULL UNIQUE: SKU (stock keeping unit) adalah kode unik gudang. Constraint UNIQUE menolak dua produk dengan SKU sama sekaligus otomatis membuat index sehingga pencarian SKU cepat — fondasi bagi method cariSku() di Bagian 2.
  2. kategori_id INTEGER NOT NULL REFERENCES categories(id): foreign key memaksa setiap produk menunjuk kategori yang benar-benar ada. Kolom ini NOT NULL karena produk tanpa kategori tidak bermakna dalam laporan nantinya.
  3. harga_satuan INTEGER NOT NULL CHECK (harga_satuan >= 0): harga disimpan sebagai integer rupiah (bukan float) agar bebas galat pembulatan. CHECK menolak harga negatif sejak awal.
  4. stok_sekarang INTEGER NOT NULL DEFAULT 0 CHECK (stok_sekarang >= 0): kolom ini adalah snapshot stok saat ini. Nilai 0 membuat 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).

sql
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:

  1. jenis ... CHECK (jenis IN ('masuk', 'keluar')): hanya dua arah pergerakan yang sah. Typo seperti 'kelar' atau 'Masuk' (huruf besar) langsung ditolak database.
  2. jumlah ... CHECK (jumlah > 0): movement nol atau negatif tidak bermakna — menambah nol barang bukanlah kejadian inventaris.
  3. keterangan TEXT sengaja boleh NULL: tidak semua kejadian punya nomor referensi, dan kolom ini murni deskriptif.
  4. 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:

sql
CREATE INDEX IF NOT EXISTS idx_products_nama ON products(nama);
CREATE INDEX IF NOT EXISTS idx_movements_product ON stock_movements(product_id);
IndexSumberManfaat
idx_products_namaCREATE INDEX eksplisitmempercepat urut dan cari nama produk
idx_movements_productCREATE INDEX eksplisitmempercepat pengambilan riwayat satu produk
sqlite_autoindex_products_1UNIQUE pada kolom skumempercepat cek duplikat dan cariSku()
sqlite_autoindex_categories_1UNIQUE pada kolom namamempercepat 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:

sql
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:

sql
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
<?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:

text
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.id

Ada 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
<?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:

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

Enam 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
<?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:

text
== 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: 1

Lima 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
<?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:

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

Ekspresi 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:

text
sistem-inventaris/
└── bagian-1-desain-database/
    ├── schema.sql
    └── uji-skema.php

Simpan snippet mana pun 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__ — 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.sql menjadi satu-satunya sumber kebenaran struktur data: tiga tabel (categories, products, stock_movements) dengan relasi kategori_id dan product_id.
  • Empat CHECK constraint aktif: stok dan harga tidak negatif, jenis movement terbatas masuk/keluar, jumlah movement selalu positif.
  • Konsep buku besar ditegakkan sejak data awal: setiap stok seed dijelaskan oleh movement pembuka sehingga rekonsiliasi langsung konsisten.
  • schema.sql idempoten: IF NOT EXISTS untuk objek, INSERT OR IGNORE untuk data master, dan guard NOT EXISTS untuk buku besar.
  • Perilaku PRAGMA foreign_keys yang 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
sql
-- 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).