Gudang kantor menyimpan puluhan jenis barang — kertas, tinta, alat tulis — dan setiap hari barangnya berpindah masuk dan keluar. Mengelola arus itu lewat catatan manual cepat menemui batas: angka stok perlahan tidak selaras dengan isi rak, tidak jelas siapa yang mengubah, dan tidak ada jejak mengapa stok berubah. Bagian 1 memulai proyek aplikasi inventaris dengan merancang fondasi datanya: tabel barang yang menyimpan keadaan persediaan terkini dan tabel mutasi yang mencatat setiap pergerakan stok sebagai jurnal. Kedua tabel lalu diterjemahkan ke tipe Java — record Barang, record MutasiStok, enum JenisMutasi — dengan validasi yang menjaga data sahih sejak objek dibuat.

Dua Tabel: barang dan mutasi #

Rancangan inventaris yang sehat memisahkan dua jenis data yang berbeda sifat. Tabel barang menyimpan keadaan terkini: kapan pun ditanya, jawabannya satu angka stok dan satu angka harga. Tabel mutasi menyimpan kejadian: barang mana yang bergerak, berapa banyak, kapan, oleh petugas siapa, dengan catatan apa. Baris mutasi tidak pernah diubah atau dihapus setelah dicatat — jurnal bersifat append-only — sehingga kejadian masa lalu tidak bisa ditulis ulang diam-diam.

Pemisahan ini memberi jejak audit — “siapa mengeluarkan 25 rim kertas pada 8 Januari?” terjawab dari satu baris jurnal — sekaligus kemampuan verifikasi: stok selalu bisa dihitung ulang dari penjumlahan mutasi. Skema berikut memakai dialek SQLite. Lingkungan artikel ini tidak menyertakan driver JDBC, sehingga seluruh kode SQL pada proyek ini berstatus rancangan; padanan Java-nya yang dijalankan dan diuji sungguhan.

sql
CREATE TABLE barang (
    kode         VARCHAR(16)   PRIMARY KEY,
    nama         VARCHAR(100)  NOT NULL,
    satuan       VARCHAR(16)   NOT NULL,
    harga        DECIMAL(12,2) NOT NULL CHECK (harga >= 0),
    stok         INT           NOT NULL CHECK (stok >= 0),
    stok_minimum INT           NOT NULL CHECK (stok_minimum >= 0)
);

CREATE TABLE mutasi (
    id          INTEGER      PRIMARY KEY AUTOINCREMENT,
    kode_barang VARCHAR(16)  NOT NULL REFERENCES barang (kode),
    jenis       VARCHAR(6)   NOT NULL CHECK (jenis IN ('MASUK', 'KELUAR')),
    jumlah      INT          NOT NULL CHECK (jumlah > 0),
    tanggal     DATE         NOT NULL,
    petugas     VARCHAR(60)  NOT NULL,
    catatan     VARCHAR(160) NOT NULL DEFAULT ''
);

Kolom kode menjadi PRIMARY KEY sehingga satu barang hanya boleh terdaftar sekali. REFERENCES barang (kode) membuat mutasi hanya bisa menunjuk barang yang benar-benar ada. CHECK menolak nilai mustahil seperti stok negatif, harga negatif, atau jumlah mutasi nol. Constraint adalah aturan bisnis yang ditegakkan mesin database itu sendiri — kode aplikasi tidak bisa melanggarnya sekalipun programnya keliru.

Padanan Java: Record dan Enum #

Record Java tidak memiliki constraint database, tetapi punya mekanisme setara: compact constructor. Seluruh pemeriksaan berikut berjalan pada setiap pembuatan objek, sehingga objek Barang dan MutasiStok yang tidak sahih tidak pernah sempat ada. Pemetaan kolom SQL ke field Java:

Kolom SQLField recordJaminan di Java
kode VARCHAR(16) PRIMARY KEYString kodeconstructor menolak nilai kosong; keunikan dijaga lapis penyimpanan
harga DECIMAL CHECK (harga >= 0)BigDecimal hargaharga.signum() < 0 melempar IllegalArgumentException
stok INT CHECK (stok >= 0)int stokvalidasi constructor yang sama
jenis CHECK (jenis IN (...))JenisMutasi jenisenum: hanya dua nilai yang mungkin
id INTEGER AUTOINCREMENTint idpenomoran diserahkan ke lapis penyimpanan

Berikut model barangnya — harga memakai BigDecimal dari string agar nilai desimal tersimpan persis, bukan hasil pembulatan biner double:

java Barang.java
import java.math.BigDecimal;

// Satu baris tabel "barang": keadaan persediaan terkini satu jenis barang.
public record Barang(
        String kode,
        String nama,
        String satuan,
        BigDecimal harga,
        int stok,
        int stokMinimum) {

    public Barang {
        if (kode == null || kode.isBlank()) {
            throw new IllegalArgumentException("kode barang tidak boleh kosong");
        }
        if (nama == null || nama.isBlank()) {
            throw new IllegalArgumentException("nama barang tidak boleh kosong");
        }
        if (satuan == null || satuan.isBlank()) {
            throw new IllegalArgumentException("satuan tidak boleh kosong");
        }
        if (harga == null || harga.signum() < 0) {
            throw new IllegalArgumentException("harga tidak boleh negatif");
        }
        if (stok < 0) {
            throw new IllegalArgumentException("stok tidak boleh negatif");
        }
        if (stokMinimum < 0) {
            throw new IllegalArgumentException("stok minimum tidak boleh negatif");
        }
    }

    // toString eksplisit: data domain saja, tanpa alamat objek.
    @Override
    public String toString() {
        return "Barang[kode=" + kode + ", nama=" + nama + ", satuan=" + satuan
                + ", harga=" + harga.toPlainString() + ", stok=" + stok
                + ", minimum=" + stokMinimum + "]";
    }
}

Arah mutasi menjadi enum dengan satu anggota data: tanda() mengembalikan +1 untuk MASUK dan -1 untuk KELUAR, sehingga penjumlahan jurnal cukup satu rumus:

java JenisMutasi.java
// Arah mutasi stok: MASUK menambah persediaan, KELUAR mengurangi.
public enum JenisMutasi {

    MASUK(1),
    KELUAR(-1);

    private final int tanda;

    JenisMutasi(int tanda) {
        this.tanda = tanda;
    }

    /** +1 untuk MASUK dan -1 untuk KELUAR, dipakai menghitung stok dari jurnal. */
    public int tanda() {
        return tanda;
    }
}

Satu baris jurnal direpresentasikan record berikut. Perhatikan normalisasi catatan pada compact constructor: nilai null dirapikan menjadi string kosong, meniru DEFAULT '' pada kolom SQL:

java MutasiStok.java
import java.time.LocalDate;

// Satu baris jurnal stok: sekali dicatat, tidak pernah diubah (append-only).
public record MutasiStok(
        int id,
        String kodeBarang,
        JenisMutasi jenis,
        int jumlah,
        LocalDate tanggal,
        String petugas,
        String catatan) {

    public MutasiStok {
        if (id < 0) {
            throw new IllegalArgumentException("id mutasi tidak boleh negatif");
        }
        if (kodeBarang == null || kodeBarang.isBlank()) {
            throw new IllegalArgumentException("kode barang tidak boleh kosong");
        }
        if (jenis == null) {
            throw new IllegalArgumentException("jenis mutasi tidak boleh null");
        }
        if (jumlah <= 0) {
            throw new IllegalArgumentException("jumlah mutasi harus positif: " + jumlah);
        }
        if (tanggal == null) {
            throw new IllegalArgumentException("tanggal tidak boleh null");
        }
        if (petugas == null || petugas.isBlank()) {
            throw new IllegalArgumentException("petugas tidak boleh kosong");
        }
        if (catatan == null) {
            catatan = "";   // catatan opsional: null dirapikan menjadi string kosong
        }
    }

    @Override
    public String toString() {
        return "MutasiStok[id=" + id + ", kode=" + kodeBarang + ", " + jenis
                + " " + jumlah + ", tanggal=" + tanggal + ", petugas=" + petugas
                + ", catatan=" + catatan + "]";
    }
}

Stok Dihitung dari Jurnal #

Demo Bagian 1 membuktikan hubungan kedua tabel: stok pada Barang harus sama dengan penjumlahan seluruh mutasi untuk kode barang itu. Kertas HVS A4 diterima 50 rim, keluar 25, masuk lagi 30, keluar 15 — hasilnya 40 rim:

java DemoGudang.java
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;

// Bagian 1: memodelkan barang dan jurnal mutasi, lalu menghitung stok dari jurnal.
public class DemoGudang {

    public static void main(String[] args) {
        Barang kertas = new Barang("BRG-001", "Kertas HVS A4", "RIM",
                new BigDecimal("47500"), 40, 10);
        Barang tinta = new Barang("BRG-002", "Tinta Printer Hitam", "BOTOL",
                new BigDecimal("185000"), 8, 4);

        System.out.println("barang 1: " + kertas);
        System.out.println("barang 2: " + tinta);

        // Jurnal mutasi BRG-001 sejak barang pertama kali diterima.
        List<MutasiStok> jurnal = List.of(
                new MutasiStok(1, "BRG-001", JenisMutasi.MASUK, 50,
                        LocalDate.of(2026, 1, 5), "Budi", "penerimaan awal"),
                new MutasiStok(2, "BRG-001", JenisMutasi.KELUAR, 25,
                        LocalDate.of(2026, 1, 8), "Siti", "permintaan divisi keuangan"),
                new MutasiStok(3, "BRG-001", JenisMutasi.MASUK, 30,
                        LocalDate.of(2026, 1, 15), "Budi", "pembelian rutin"),
                new MutasiStok(4, "BRG-001", JenisMutasi.KELUAR, 15,
                        LocalDate.of(2026, 1, 20), "Dewi", "permintaan divisi pemasaran"));

        for (MutasiStok mutasi : jurnal) {
            System.out.println("mutasi  : " + mutasi);
        }

        // Stok saat ini = penjumlahan seluruh jurnal: 50 - 25 + 30 - 15.
        int stokDariJurnal = 0;
        for (MutasiStok mutasi : jurnal) {
            stokDariJurnal += mutasi.jenis().tanda() * mutasi.jumlah();
        }
        System.out.println("stok dari jurnal       : " + stokDariJurnal);
        System.out.println("stok pada barang       : " + kertas.stok());
        System.out.println("jurnal dan stok selaras: " + (stokDariJurnal == kertas.stok()));

        // Validasi record menolak jumlah mutasi yang tidak positif.
        try {
            new MutasiStok(5, "BRG-001", JenisMutasi.KELUAR, -10,
                    LocalDate.of(2026, 1, 21), "Siti", "salah ketik");
        } catch (IllegalArgumentException e) {
            System.out.println("ditolak : " + e.getMessage());
        }
    }
}

Output yang Dihasilkan:

text
barang 1: Barang[kode=BRG-001, nama=Kertas HVS A4, satuan=RIM, harga=47500, stok=40, minimum=10]
barang 2: Barang[kode=BRG-002, nama=Tinta Printer Hitam, satuan=BOTOL, harga=185000, stok=8, minimum=4]
mutasi  : MutasiStok[id=1, kode=BRG-001, MASUK 50, tanggal=2026-01-05, petugas=Budi, catatan=penerimaan awal]
mutasi  : MutasiStok[id=2, kode=BRG-001, KELUAR 25, tanggal=2026-01-08, petugas=Siti, catatan=permintaan divisi keuangan]
mutasi  : MutasiStok[id=3, kode=BRG-001, MASUK 30, tanggal=2026-01-15, petugas=Budi, catatan=pembelian rutin]
mutasi  : MutasiStok[id=4, kode=BRG-001, KELUAR 15, tanggal=2026-01-20, petugas=Dewi, catatan=permintaan divisi pemasaran]
stok dari jurnal       : 40
stok pada barang       : 40
jurnal dan stok selaras: true
ditolak : jumlah mutasi harus positif: -10

Mari kita bedah bagian-bagian pentingnya:

  1. Pembuatan Barang: harga dibuat dari string new BigDecimal("47500") sehingga tersimpan persis tanpa galat biner; toPlainString() mencetaknya tanpa notasi ilmiah.
  2. Jurnal List.of(...): empat mutasi diberi id manual 1 sampai 4 — di Bagian 2 penomoran diserahkan ke lapisan penyimpanan sebagai padanan AUTOINCREMENT.
  3. Loop penjumlahan: method tanda() mengubah jenis menjadi +1 atau -1, sehingga MASUK menambah dan KELUAR mengurangi tanpa percabangan.
  4. Baris jurnal dan stok selaras: true: hasil hitung ulang 50 - 25 + 30 - 15 sama persis dengan field stok bernilai 40 — invarian yang diuji otomatis pada Bagian 4.
  5. Blok try terakhir: mutasi dengan jumlah -10 ditolak compact constructor dengan pesan jumlah mutasi harus positif: -10, setara CHECK (jumlah > 0).

Model sudah berdiri, tetapi objek-objeknya masih dibuat dan dipegang manual di method main. Bagian 2 mengangkatnya menjadi aplikasi: layanan Gudang dengan aturan bisnis, exception domain untuk setiap pelanggaran, dan lapisan penyimpanan abstrak yang kelak bisa diganti database sungguhan.

Referensi #

Berkas Sumber

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

Barang.java

Unduh berkas
java
import java.math.BigDecimal;

// Satu baris tabel "barang": keadaan persediaan terkini satu jenis barang.
public record Barang(
        String kode,
        String nama,
        String satuan,
        BigDecimal harga,
        int stok,
        int stokMinimum) {

    public Barang {
        if (kode == null || kode.isBlank()) {
            throw new IllegalArgumentException("kode barang tidak boleh kosong");
        }
        if (nama == null || nama.isBlank()) {
            throw new IllegalArgumentException("nama barang tidak boleh kosong");
        }
        if (satuan == null || satuan.isBlank()) {
            throw new IllegalArgumentException("satuan tidak boleh kosong");
        }
        if (harga == null || harga.signum() < 0) {
            throw new IllegalArgumentException("harga tidak boleh negatif");
        }
        if (stok < 0) {
            throw new IllegalArgumentException("stok tidak boleh negatif");
        }
        if (stokMinimum < 0) {
            throw new IllegalArgumentException("stok minimum tidak boleh negatif");
        }
    }

    // toString eksplisit: data domain saja, tanpa alamat objek.
    @Override
    public String toString() {
        return "Barang[kode=" + kode + ", nama=" + nama + ", satuan=" + satuan
                + ", harga=" + harga.toPlainString() + ", stok=" + stok
                + ", minimum=" + stokMinimum + "]";
    }
}

DemoGudang.java

Unduh berkas
java
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;

// Bagian 1: memodelkan barang dan jurnal mutasi, lalu menghitung stok dari jurnal.
public class DemoGudang {

    public static void main(String[] args) {
        Barang kertas = new Barang("BRG-001", "Kertas HVS A4", "RIM",
                new BigDecimal("47500"), 40, 10);
        Barang tinta = new Barang("BRG-002", "Tinta Printer Hitam", "BOTOL",
                new BigDecimal("185000"), 8, 4);

        System.out.println("barang 1: " + kertas);
        System.out.println("barang 2: " + tinta);

        // Jurnal mutasi BRG-001 sejak barang pertama kali diterima.
        List<MutasiStok> jurnal = List.of(
                new MutasiStok(1, "BRG-001", JenisMutasi.MASUK, 50,
                        LocalDate.of(2026, 1, 5), "Budi", "penerimaan awal"),
                new MutasiStok(2, "BRG-001", JenisMutasi.KELUAR, 25,
                        LocalDate.of(2026, 1, 8), "Siti", "permintaan divisi keuangan"),
                new MutasiStok(3, "BRG-001", JenisMutasi.MASUK, 30,
                        LocalDate.of(2026, 1, 15), "Budi", "pembelian rutin"),
                new MutasiStok(4, "BRG-001", JenisMutasi.KELUAR, 15,
                        LocalDate.of(2026, 1, 20), "Dewi", "permintaan divisi pemasaran"));

        for (MutasiStok mutasi : jurnal) {
            System.out.println("mutasi  : " + mutasi);
        }

        // Stok saat ini = penjumlahan seluruh jurnal: 50 - 25 + 30 - 15.
        int stokDariJurnal = 0;
        for (MutasiStok mutasi : jurnal) {
            stokDariJurnal += mutasi.jenis().tanda() * mutasi.jumlah();
        }
        System.out.println("stok dari jurnal       : " + stokDariJurnal);
        System.out.println("stok pada barang       : " + kertas.stok());
        System.out.println("jurnal dan stok selaras: " + (stokDariJurnal == kertas.stok()));

        // Validasi record menolak jumlah mutasi yang tidak positif.
        try {
            new MutasiStok(5, "BRG-001", JenisMutasi.KELUAR, -10,
                    LocalDate.of(2026, 1, 21), "Siti", "salah ketik");
        } catch (IllegalArgumentException e) {
            System.out.println("ditolak : " + e.getMessage());
        }
    }
}

JenisMutasi.java

Unduh berkas
java
// Arah mutasi stok: MASUK menambah persediaan, KELUAR mengurangi.
public enum JenisMutasi {

    MASUK(1),
    KELUAR(-1);

    private final int tanda;

    JenisMutasi(int tanda) {
        this.tanda = tanda;
    }

    /** +1 untuk MASUK dan -1 untuk KELUAR, dipakai menghitung stok dari jurnal. */
    public int tanda() {
        return tanda;
    }
}

MutasiStok.java

Unduh berkas
java
import java.time.LocalDate;

// Satu baris jurnal stok: sekali dicatat, tidak pernah diubah (append-only).
public record MutasiStok(
        int id,
        String kodeBarang,
        JenisMutasi jenis,
        int jumlah,
        LocalDate tanggal,
        String petugas,
        String catatan) {

    public MutasiStok {
        if (id < 0) {
            throw new IllegalArgumentException("id mutasi tidak boleh negatif");
        }
        if (kodeBarang == null || kodeBarang.isBlank()) {
            throw new IllegalArgumentException("kode barang tidak boleh kosong");
        }
        if (jenis == null) {
            throw new IllegalArgumentException("jenis mutasi tidak boleh null");
        }
        if (jumlah <= 0) {
            throw new IllegalArgumentException("jumlah mutasi harus positif: " + jumlah);
        }
        if (tanggal == null) {
            throw new IllegalArgumentException("tanggal tidak boleh null");
        }
        if (petugas == null || petugas.isBlank()) {
            throw new IllegalArgumentException("petugas tidak boleh kosong");
        }
        if (catatan == null) {
            catatan = "";   // catatan opsional: null dirapikan menjadi string kosong
        }
    }

    @Override
    public String toString() {
        return "MutasiStok[id=" + id + ", kode=" + kodeBarang + ", " + jenis
                + " " + jumlah + ", tanggal=" + tanggal + ", petugas=" + petugas
                + ", catatan=" + catatan + "]";
    }
}