Model Bagian 1 masih pasif: record dibuat lalu ditinggalkan di method main. Bagian 2 menghidupkannya menjadi layanan yang mengawal setiap pergerakan stok. Lima aturan bisnis ditegakkan di satu class Gudang: barang harus terdaftar sebelum bergerak, jumlah mutasi harus positif, stok tidak boleh minus, setiap perubahan stok wajib meninggalkan baris jurnal, dan kode barang tidak boleh didaftarkan dua kali. Pelanggaran diterjemahkan menjadi exception domain bernama jelas, sementara detail penyimpanan disembunyikan di balik interface GudangRepository.

Aturan Bisnis Transaksi #

Aturan bisnis paling penting justru yang melingkupi kelima di atas:

Lima aturan lainnya dipetakan ke kegagalan yang dinamai:

  1. Barang harus terdaftar — kode tak dikenal melempar BarangTidakDitemukanException.
  2. Jumlah harus positif — nol atau negatif ditolak di gerbang layanan, padanan CHECK (jumlah > 0).
  3. Stok tidak boleh minus — permintaan melebihi stok melempar StokTidakCukupException.
  4. Setiap perubahan meninggalkan jurnal — hanya method barangMasuk dan barangKeluar yang boleh mengubah stok, dan keduanya selalu mencatat mutasi.
  5. Kode barang unik — pendaftaran ulang ditolak lapis penyimpanan, padanan pelanggaran PRIMARY KEY.

Exception Domain #

Tiga exception baru semuanya mewarisi GudangException yang merupakan turunan RuntimeException. Pilihan unchecked disengaja: pelanggaran aturan stok adalah kesalahan pemanggilan, bukan kondisi yang layak dicoba ulang — biarkan exception merambat sampai kode yang keliru diperbaiki.

// Akar exception domain gudang: seluruh pelanggaran aturan bisnis bermuara ke sini.
public class GudangException extends RuntimeException {

    public GudangException(String pesan) {
        super(pesan);
    }

    // Pemungkusan penyebab teknis (mis. kegagalan berkas pada Bagian 3).
    public GudangException(String pesan, Throwable penyebab) {
        super(pesan, penyebab);
    }
}
// Operasi menunjuk kode barang yang tidak terdaftar di penyimpanan.
public class BarangTidakDitemukanException extends GudangException {

    public BarangTidakDitemukanException(String pesan) {
        super(pesan);
    }
}
// Permintaan barang keluar melebihi stok yang tersedia saat itu.
public class StokTidakCukupException extends GudangException {

    public StokTidakCukupException(String pesan) {
        super(pesan);
    }
}

Lapisan Penyimpanan Abstrak #

Layanan Gudang tidak mau tahu data tinggal di mana. Kontraknya ditulis sebagai interface dengan enam method, tiap method padanan satu operasi SQL: simpan = INSERT INTO barang, cari = SELECT ... WHERE kode = ?, perbaruiStok = UPDATE barang SET stok = ?, dan catat = INSERT INTO mutasi yang mengembalikan mutasi ber-id baru — padanan kolom identitas yang diisi database.

import java.util.List;

// Abstraksi penyimpanan gudang. Layanan Gudang hanya mengenal interface ini,
// sehingga lapisan penyimpanan dapat diganti (memori, berkas, JDBC)
// tanpa menyentuh satu baris aturan bisnis pun.
public interface GudangRepository {

    /** Mendaftarkan barang baru; menolak kode yang sudah terdaftar. */
    void simpan(Barang barang);

    /** Mencari satu barang berdasarkan kode; null bila tidak terdaftar. */
    Barang cari(String kode);

    /** Seluruh barang, terurut naik berdasarkan kode. */
    List<Barang> semuaBarang();

    /** Memperbarui stok satu barang ke nilai baru. */
    void perbaruiStok(String kode, int stokBaru);

    /** Mencatat mutasi (id 0 pada input diganti id baru) dan mengembalikannya. */
    MutasiStok catat(MutasiStok mutasi);

    /** Seluruh mutasi, terurut naik berdasarkan id. */
    List<MutasiStok> riwayat();
}

Implementasi pertama menyimpan segalanya di memori. LinkedHashMap dipilih agar urutan sisip terjaga dan keluaran program selalu sama antar eksekusi, sementara counter idBerikutnya menggantikan AUTOINCREMENT. Nilai balik null dari cari dibiarkan polos: memaknai “tidak ada” sebagai error adalah aturan bisnis layanan, bukan urusan penyimpanan.

import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

// Penyimpanan dalam memori: tanpa berkas dan tanpa database, seluruh data
// hilang saat JVM berhenti. Cepat dan deterministik untuk pengujian aturan.
public final class GudangRepositoryMemori implements GudangRepository {

    // LinkedHashMap menjaga urutan sisip sehingga keluaran selalu stabil.
    private final Map<String, Barang> tabelBarang = new LinkedHashMap<>();
    private final List<MutasiStok> tabelMutasi = new ArrayList<>();
    private int idBerikutnya = 1;

    @Override
    public void simpan(Barang barang) {
        Barang sebelumnya = tabelBarang.putIfAbsent(barang.kode(), barang);
        if (sebelumnya != null) {
            throw new GudangException("kode barang sudah terdaftar: " + barang.kode());
        }
    }

    @Override
    public Barang cari(String kode) {
        return tabelBarang.get(kode);
    }

    @Override
    public List<Barang> semuaBarang() {
        return tabelBarang.values().stream()
                .sorted(Comparator.comparing(Barang::kode))
                .toList();
    }

    @Override
    public void perbaruiStok(String kode, int stokBaru) {
        Barang barang = cariWajib(kode);
        tabelBarang.put(kode, barang.denganStok(stokBaru));
    }

    @Override
    public MutasiStok catat(MutasiStok mutasi) {
        MutasiStok tersimpan = new MutasiStok(idBerikutnya, mutasi.kodeBarang(),
                mutasi.jenis(), mutasi.jumlah(), mutasi.tanggal(),
                mutasi.petugas(), mutasi.catatan());
        idBerikutnya++;
        tabelMutasi.add(tersimpan);
        return tersimpan;
    }

    @Override
    public List<MutasiStok> riwayat() {
        return List.copyOf(tabelMutasi);
    }

    private Barang cariWajib(String kode) {
        Barang barang = tabelBarang.get(kode);
        if (barang == null) {
            throw new GudangException("kode barang tidak terdaftar: " + kode);
        }
        return barang;
    }
}

Layanan Gudang #

Class Gudang adalah satu-satunya pintu mengubah stok. Pemeriksaan berjalan berurutan — cari barang, periksa jumlah, periksa stok — sehingga penolakan terjadi sebelum jurnal dicatat dan stok diperbarui:

java Gudang.java
import java.time.LocalDate;
import java.util.List;

// Layanan gudang: satu-satunya pintu mengubah stok. Seluruh aturan bisnis
// diperiksa di sini sebelum penyimpanan disentuh.
public class Gudang {

    private final GudangRepository repository;

    public Gudang(GudangRepository repository) {
        this.repository = repository;
    }

    /** Mendaftarkan barang baru, selalu dengan stok awal yang diberikan. */
    public Barang daftar(Barang barang) {
        repository.simpan(barang);
        return barang;
    }

    /** Menambah stok dan mencatat mutasi MASUK dalam satu operasi. */
    public MutasiStok barangMasuk(String kode, int jumlah, LocalDate tanggal,
            String petugas, String catatan) {
        Barang barang = cariWajib(kode);
        cekJumlahPositif(jumlah);
        MutasiStok mutasi = repository.catat(new MutasiStok(0, kode,
                JenisMutasi.MASUK, jumlah, tanggal, petugas, catatan));
        repository.perbaruiStok(kode, barang.stok() + jumlah);
        return mutasi;
    }

    /** Mengurangi stok dan mencatat mutasi KELUAR; menolak bila stok kurang. */
    public MutasiStok barangKeluar(String kode, int jumlah, LocalDate tanggal,
            String petugas, String catatan) {
        Barang barang = cariWajib(kode);
        cekJumlahPositif(jumlah);
        if (barang.stok() < jumlah) {
            throw new StokTidakCukupException("stok " + barang.nama() + " tersisa "
                    + barang.stok() + " " + barang.satuan() + ", diminta " + jumlah);
        }
        MutasiStok mutasi = repository.catat(new MutasiStok(0, kode,
                JenisMutasi.KELUAR, jumlah, tanggal, petugas, catatan));
        repository.perbaruiStok(kode, barang.stok() - jumlah);
        return mutasi;
    }

    /** Satu barang berdasarkan kode; melempar bila tidak terdaftar. */
    public Barang cariBarang(String kode) {
        return cariWajib(kode);
    }

    /** Seluruh barang, terurut naik berdasarkan kode. */
    public List<Barang> semuaBarang() {
        return repository.semuaBarang();
    }

    /** Seluruh mutasi dalam urutan pencatatan. */
    public List<MutasiStok> riwayat() {
        return repository.riwayat();
    }

    private Barang cariWajib(String kode) {
        Barang barang = repository.cari(kode);
        if (barang == null) {
            throw new BarangTidakDitemukanException(
                    "kode barang tidak terdaftar: " + kode);
        }
        return barang;
    }

    private static void cekJumlahPositif(int jumlah) {
        if (jumlah <= 0) {
            throw new GudangException("jumlah mutasi harus positif: " + jumlah);
        }
    }
}

Dua keputusan penting di dalamnya. Pertama, penolakan terjadi sebelum repository.catat dipanggil, jadi transaksi yang ditolak tidak menyisakan baris jurnal setengah jadi. Kedua, catat dikerjakan sebelum perbaruiStok — pada penyimpanan memori keduanya mustahil gagal di tengah, tetapi pada database kegagalan di sela dua langkah dimungkinkan; itu dijawab transaksi JDBC di akhir halaman.

Demo: Diterima dan Ditolak #

Skenario berikut mendaftarkan tiga barang dengan stok nol, mengalirkan dua penerimaan dan satu permintaan, lalu melempar empat pelanggaran untuk membuktikan semuanya ditolak dengan pesan yang menjelaskan:

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

// Bagian 2: aturan bisnis transaksi masuk dan keluar pada layanan Gudang.
public class DemoGudang {

    public static void main(String[] args) {
        Gudang gudang = new Gudang(new GudangRepositoryMemori());
        isiData(gudang);

        // 1. Barang terdaftar dengan stok nol: stok hanya berubah lewat mutasi.
        System.out.println("terdaftar: " + gudang.cariBarang("BRG-001"));

        // 2. Penerimaan barang menaikkan stok dan tercatat di jurnal.
        System.out.println("masuk   1: " + gudang.barangMasuk("BRG-001", 50,
                LocalDate.of(2026, 1, 5), "Budi", "penerimaan awal"));
        System.out.println("stok     : " + gudang.cariBarang("BRG-001").stok() + " RIM");
        System.out.println("masuk   2: " + gudang.barangMasuk("BRG-001", 30,
                LocalDate.of(2026, 1, 10), "Budi", "pembelian rutin"));
        System.out.println("stok     : " + gudang.cariBarang("BRG-001").stok() + " RIM");

        // 3. Permintaan barang menurunkan stok.
        System.out.println("keluar  1: " + gudang.barangKeluar("BRG-001", 25,
                LocalDate.of(2026, 1, 15), "Siti", "permintaan divisi keuangan"));
        System.out.println("stok     : " + gudang.cariBarang("BRG-001").stok() + " RIM");

        // 4. Keluar melebihi stok: ditolak, stok dan jurnal tidak berubah.
        tolak(() -> gudang.barangKeluar("BRG-001", 100,
                LocalDate.of(2026, 1, 20), "Dewi", "permintaan divisi pemasaran"));
        System.out.println("stok     : " + gudang.cariBarang("BRG-001").stok() + " RIM");

        // 5. Kode yang tidak terdaftar ditolak sebelum penyimpanan disentuh.
        tolak(() -> gudang.barangKeluar("BRG-009", 1,
                LocalDate.of(2026, 1, 21), "Siti", "kode salah ketik"));

        // 6. Jumlah tidak positif ditolak di gerbang layanan.
        tolak(() -> gudang.barangMasuk("BRG-001", -5,
                LocalDate.of(2026, 1, 21), "Budi", "salah ketik"));

        // 7. Kode ganda ditolak lapis penyimpanan (padanan pelanggaran PRIMARY KEY).
        tolak(() -> gudang.daftar(new Barang("BRG-001", "Kertas HVS A4", "RIM",
                new BigDecimal("47500"), 0, 10)));

        // 8. Tabel akhir dan jurnal utuh dalam urutan pencatatan.
        System.out.println();
        System.out.println("== Tabel barang ==");
        for (Barang b : gudang.semuaBarang()) {
            System.out.printf(Locale.US, "%-8s %-22s %-6s %11s %6d%n",
                    b.kode(), b.nama(), b.satuan(), formatRupiah(b.harga()), b.stok());
        }
        System.out.println("== Riwayat mutasi ==");
        for (MutasiStok m : gudang.riwayat()) {
            System.out.printf(Locale.US, "#%d %s %-6s %-8s %3d  %s%n",
                    m.id(), m.tanggal(), m.jenis(), m.kodeBarang(), m.jumlah(),
                    m.petugas());
        }
    }

    /** Menjalankan operasi dan mencetak pesan exception domain yang dilempar. */
    private static void tolak(Runnable operasi) {
        try {
            operasi.run();
            System.out.println("diterima: seharusnya ditolak");
        } catch (GudangException e) {
            System.out.println("ditolak : " + e.getMessage());
        }
    }

    static void isiData(Gudang gudang) {
        gudang.daftar(new Barang("BRG-001", "Kertas HVS A4", "RIM",
                new BigDecimal("47500"), 0, 10));
        gudang.daftar(new Barang("BRG-002", "Tinta Printer Hitam", "BOTOL",
                new BigDecimal("185000"), 0, 4));
        gudang.daftar(new Barang("BRG-003", "Pulpen Gel Hitam 0.5", "BOX",
                new BigDecimal("62000"), 0, 5));
    }

    // Format uang sederhana: 185000 menjadi "Rp185.000".
    static String formatRupiah(BigDecimal nilai) {
        return "Rp" + nilai.setScale(0).toPlainString()
                .replaceAll("\\B(?=(\\d{3})+(?!\\d))", ".");
    }
}

Output yang Dihasilkan:

text
terdaftar: Barang[kode=BRG-001, nama=Kertas HVS A4, satuan=RIM, harga=47500, stok=0, minimum=10]
masuk   1: MutasiStok[id=1, kode=BRG-001, MASUK 50, tanggal=2026-01-05, petugas=Budi, catatan=penerimaan awal]
stok     : 50 RIM
masuk   2: MutasiStok[id=2, kode=BRG-001, MASUK 30, tanggal=2026-01-10, petugas=Budi, catatan=pembelian rutin]
stok     : 80 RIM
keluar  1: MutasiStok[id=3, kode=BRG-001, KELUAR 25, tanggal=2026-01-15, petugas=Siti, catatan=permintaan divisi keuangan]
stok     : 55 RIM
ditolak : stok Kertas HVS A4 tersisa 55 RIM, diminta 100
stok     : 55 RIM
ditolak : kode barang tidak terdaftar: BRG-009
ditolak : jumlah mutasi harus positif: -5
ditolak : kode barang sudah terdaftar: BRG-001

== Tabel barang ==
BRG-001  Kertas HVS A4          RIM       Rp47.500     55
BRG-002  Tinta Printer Hitam    BOTOL    Rp185.000      0
BRG-003  Pulpen Gel Hitam 0.5   BOX       Rp62.000      0
== Riwayat mutasi ==
#1 2026-01-05 MASUK  BRG-001   50  Budi
#2 2026-01-10 MASUK  BRG-001   30  Budi
#3 2026-01-15 KELUAR BRG-001   25  Siti

Mari kita baca keluarannya:

  1. terdaftar sampai stok : 55 RIM: stok naik 0 → 50 → 80 lalu turun ke 55; setiap langkah menyisakan satu baris MutasiStok dengan id otomatis dari catat.
  2. ditolak : stok Kertas HVS A4 tersisa 55 RIM, diminta 100: StokTidakCukupException menyebut nama barang, sisa stok, satuan, dan jumlah diminta; baris stok berikutnya membuktikan angkanya tidak bergeser.
  3. Tiga penolakan terakhir: kode BRG-009 tidak terdaftar, jumlah -5 tidak positif dan tertahan sebelum record MutasiStok sempat dibuat, serta pendaftaran ulang BRG-001 ditolak putIfAbsent di lapis penyimpanan.
  4. Tabel akhir: tiga barang terurut kode dengan harga terformat BigDecimal, dan riwayat utuh tiga baris — tidak ada jurnal untuk transaksi yang ditolak.

Transaksi JDBC sebagai Rancangan #

Kini padanan database-nya. Lingkungan artikel ini tidak menyertakan driver JDBC, jadi blok berikut berstatus rancangan yang siap dijalankan begitu driver (misalnya sqlite-jdbc) berada di classpath. Implementasi JDBC dari GudangRepository akan bekerja di atas Connection seperti ini:

java
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;

// Satu transaksi masuk/keluar: dua perubahan menempel bersama atau batal bersama.
void catatTransaksi(Connection koneksi, String kode, boolean masuk, int jumlah,
        String petugas, String catatan) throws SQLException {
    try {
        koneksi.setAutoCommit(false);   // matikan autocommit: transaksi dimulai
        try (PreparedStatement update = koneksi.prepareStatement(
                "UPDATE barang SET stok = stok + ? WHERE kode = ?")) {
            update.setInt(1, masuk ? jumlah : -jumlah);
            update.setString(2, kode);
            update.executeUpdate();
        }
        try (PreparedStatement insert = koneksi.prepareStatement(
                "INSERT INTO mutasi (kode_barang, jenis, jumlah, tanggal, petugas, catatan) "
                        + "VALUES (?, ?, ?, CURRENT_DATE, ?, ?)")) {
            insert.setString(1, kode);
            insert.setString(2, masuk ? "MASUK" : "KELUAR");
            insert.setInt(3, jumlah);
            insert.setString(4, petugas);
            insert.setString(5, catatan);
            insert.executeUpdate();
        }
        koneksi.commit();               // kedua perubahan menempel bersama
    } catch (SQLException e) {
        koneksi.rollback();             // satu gagal: keduanya dibatalkan
        throw e;
    }
}

Tiga hal terjadi di sini. setAutoCommit(false) mematikan perilaku bawaan JDBC yang menempelkan setiap pernyataan seketika, sehingga UPDATE dan INSERT hidup di zona tunggu. commit() menempelkan keduanya sekaligus. rollback() di catch membatalkan keduanya bila salah satu gagal — misalnya UPDATE menabrak CHECK (stok >= 0) akibat stok minus, jurnal tidak akan menempel tanpa pasangannya.

Stok dan jurnal kini bergerak disiplin di memori. Satu kelemahan masih terasa: GudangRepositoryMemori kehilangan seluruh data begitu JVM berhenti. Bagian 3 mengisinya dengan penyimpanan berkas yang teruji, plus pencarian dan filter barang.

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");
        }
    }

    // Salinan barang yang sama dengan stok baru: record tidak bisa diubah,
    // jadi "memperbarui stok" berarti membuat instance pengganti.
    public Barang denganStok(int stokBaru) {
        return new Barang(kode, nama, satuan, harga, stokBaru, stokMinimum);
    }

    // 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 + "]";
    }
}

BarangTidakDitemukanException.java

Unduh berkas
java
// Operasi menunjuk kode barang yang tidak terdaftar di penyimpanan.
public class BarangTidakDitemukanException extends GudangException {

    public BarangTidakDitemukanException(String pesan) {
        super(pesan);
    }
}

DemoGudang.java

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

// Bagian 2: aturan bisnis transaksi masuk dan keluar pada layanan Gudang.
public class DemoGudang {

    public static void main(String[] args) {
        Gudang gudang = new Gudang(new GudangRepositoryMemori());
        isiData(gudang);

        // 1. Barang terdaftar dengan stok nol: stok hanya berubah lewat mutasi.
        System.out.println("terdaftar: " + gudang.cariBarang("BRG-001"));

        // 2. Penerimaan barang menaikkan stok dan tercatat di jurnal.
        System.out.println("masuk   1: " + gudang.barangMasuk("BRG-001", 50,
                LocalDate.of(2026, 1, 5), "Budi", "penerimaan awal"));
        System.out.println("stok     : " + gudang.cariBarang("BRG-001").stok() + " RIM");
        System.out.println("masuk   2: " + gudang.barangMasuk("BRG-001", 30,
                LocalDate.of(2026, 1, 10), "Budi", "pembelian rutin"));
        System.out.println("stok     : " + gudang.cariBarang("BRG-001").stok() + " RIM");

        // 3. Permintaan barang menurunkan stok.
        System.out.println("keluar  1: " + gudang.barangKeluar("BRG-001", 25,
                LocalDate.of(2026, 1, 15), "Siti", "permintaan divisi keuangan"));
        System.out.println("stok     : " + gudang.cariBarang("BRG-001").stok() + " RIM");

        // 4. Keluar melebihi stok: ditolak, stok dan jurnal tidak berubah.
        tolak(() -> gudang.barangKeluar("BRG-001", 100,
                LocalDate.of(2026, 1, 20), "Dewi", "permintaan divisi pemasaran"));
        System.out.println("stok     : " + gudang.cariBarang("BRG-001").stok() + " RIM");

        // 5. Kode yang tidak terdaftar ditolak sebelum penyimpanan disentuh.
        tolak(() -> gudang.barangKeluar("BRG-009", 1,
                LocalDate.of(2026, 1, 21), "Siti", "kode salah ketik"));

        // 6. Jumlah tidak positif ditolak di gerbang layanan.
        tolak(() -> gudang.barangMasuk("BRG-001", -5,
                LocalDate.of(2026, 1, 21), "Budi", "salah ketik"));

        // 7. Kode ganda ditolak lapis penyimpanan (padanan pelanggaran PRIMARY KEY).
        tolak(() -> gudang.daftar(new Barang("BRG-001", "Kertas HVS A4", "RIM",
                new BigDecimal("47500"), 0, 10)));

        // 8. Tabel akhir dan jurnal utuh dalam urutan pencatatan.
        System.out.println();
        System.out.println("== Tabel barang ==");
        for (Barang b : gudang.semuaBarang()) {
            System.out.printf(Locale.US, "%-8s %-22s %-6s %11s %6d%n",
                    b.kode(), b.nama(), b.satuan(), formatRupiah(b.harga()), b.stok());
        }
        System.out.println("== Riwayat mutasi ==");
        for (MutasiStok m : gudang.riwayat()) {
            System.out.printf(Locale.US, "#%d %s %-6s %-8s %3d  %s%n",
                    m.id(), m.tanggal(), m.jenis(), m.kodeBarang(), m.jumlah(),
                    m.petugas());
        }
    }

    /** Menjalankan operasi dan mencetak pesan exception domain yang dilempar. */
    private static void tolak(Runnable operasi) {
        try {
            operasi.run();
            System.out.println("diterima: seharusnya ditolak");
        } catch (GudangException e) {
            System.out.println("ditolak : " + e.getMessage());
        }
    }

    static void isiData(Gudang gudang) {
        gudang.daftar(new Barang("BRG-001", "Kertas HVS A4", "RIM",
                new BigDecimal("47500"), 0, 10));
        gudang.daftar(new Barang("BRG-002", "Tinta Printer Hitam", "BOTOL",
                new BigDecimal("185000"), 0, 4));
        gudang.daftar(new Barang("BRG-003", "Pulpen Gel Hitam 0.5", "BOX",
                new BigDecimal("62000"), 0, 5));
    }

    // Format uang sederhana: 185000 menjadi "Rp185.000".
    static String formatRupiah(BigDecimal nilai) {
        return "Rp" + nilai.setScale(0).toPlainString()
                .replaceAll("\\B(?=(\\d{3})+(?!\\d))", ".");
    }
}

Gudang.java

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

// Layanan gudang: satu-satunya pintu mengubah stok. Seluruh aturan bisnis
// diperiksa di sini sebelum penyimpanan disentuh.
public class Gudang {

    private final GudangRepository repository;

    public Gudang(GudangRepository repository) {
        this.repository = repository;
    }

    /** Mendaftarkan barang baru, selalu dengan stok awal yang diberikan. */
    public Barang daftar(Barang barang) {
        repository.simpan(barang);
        return barang;
    }

    /** Menambah stok dan mencatat mutasi MASUK dalam satu operasi. */
    public MutasiStok barangMasuk(String kode, int jumlah, LocalDate tanggal,
            String petugas, String catatan) {
        Barang barang = cariWajib(kode);
        cekJumlahPositif(jumlah);
        MutasiStok mutasi = repository.catat(new MutasiStok(0, kode,
                JenisMutasi.MASUK, jumlah, tanggal, petugas, catatan));
        repository.perbaruiStok(kode, barang.stok() + jumlah);
        return mutasi;
    }

    /** Mengurangi stok dan mencatat mutasi KELUAR; menolak bila stok kurang. */
    public MutasiStok barangKeluar(String kode, int jumlah, LocalDate tanggal,
            String petugas, String catatan) {
        Barang barang = cariWajib(kode);
        cekJumlahPositif(jumlah);
        if (barang.stok() < jumlah) {
            throw new StokTidakCukupException("stok " + barang.nama() + " tersisa "
                    + barang.stok() + " " + barang.satuan() + ", diminta " + jumlah);
        }
        MutasiStok mutasi = repository.catat(new MutasiStok(0, kode,
                JenisMutasi.KELUAR, jumlah, tanggal, petugas, catatan));
        repository.perbaruiStok(kode, barang.stok() - jumlah);
        return mutasi;
    }

    /** Satu barang berdasarkan kode; melempar bila tidak terdaftar. */
    public Barang cariBarang(String kode) {
        return cariWajib(kode);
    }

    /** Seluruh barang, terurut naik berdasarkan kode. */
    public List<Barang> semuaBarang() {
        return repository.semuaBarang();
    }

    /** Seluruh mutasi dalam urutan pencatatan. */
    public List<MutasiStok> riwayat() {
        return repository.riwayat();
    }

    private Barang cariWajib(String kode) {
        Barang barang = repository.cari(kode);
        if (barang == null) {
            throw new BarangTidakDitemukanException(
                    "kode barang tidak terdaftar: " + kode);
        }
        return barang;
    }

    private static void cekJumlahPositif(int jumlah) {
        if (jumlah <= 0) {
            throw new GudangException("jumlah mutasi harus positif: " + jumlah);
        }
    }
}

GudangException.java

Unduh berkas
java
// Akar exception domain gudang: seluruh pelanggaran aturan bisnis bermuara ke sini.
public class GudangException extends RuntimeException {

    public GudangException(String pesan) {
        super(pesan);
    }

    // Pemungkusan penyebab teknis (mis. kegagalan berkas pada Bagian 3).
    public GudangException(String pesan, Throwable penyebab) {
        super(pesan, penyebab);
    }
}

GudangRepository.java

Unduh berkas
java
import java.util.List;

// Abstraksi penyimpanan gudang. Layanan Gudang hanya mengenal interface ini,
// sehingga lapisan penyimpanan dapat diganti (memori, berkas, JDBC)
// tanpa menyentuh satu baris aturan bisnis pun.
public interface GudangRepository {

    /** Mendaftarkan barang baru; menolak kode yang sudah terdaftar. */
    void simpan(Barang barang);

    /** Mencari satu barang berdasarkan kode; null bila tidak terdaftar. */
    Barang cari(String kode);

    /** Seluruh barang, terurut naik berdasarkan kode. */
    List<Barang> semuaBarang();

    /** Memperbarui stok satu barang ke nilai baru. */
    void perbaruiStok(String kode, int stokBaru);

    /** Mencatat mutasi (id 0 pada input diganti id baru) dan mengembalikannya. */
    MutasiStok catat(MutasiStok mutasi);

    /** Seluruh mutasi, terurut naik berdasarkan id. */
    List<MutasiStok> riwayat();
}

GudangRepositoryMemori.java

Unduh berkas
java
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

// Penyimpanan dalam memori: tanpa berkas dan tanpa database, seluruh data
// hilang saat JVM berhenti. Cepat dan deterministik untuk pengujian aturan.
public final class GudangRepositoryMemori implements GudangRepository {

    // LinkedHashMap menjaga urutan sisip sehingga keluaran selalu stabil.
    private final Map<String, Barang> tabelBarang = new LinkedHashMap<>();
    private final List<MutasiStok> tabelMutasi = new ArrayList<>();
    private int idBerikutnya = 1;

    @Override
    public void simpan(Barang barang) {
        Barang sebelumnya = tabelBarang.putIfAbsent(barang.kode(), barang);
        if (sebelumnya != null) {
            throw new GudangException("kode barang sudah terdaftar: " + barang.kode());
        }
    }

    @Override
    public Barang cari(String kode) {
        return tabelBarang.get(kode);
    }

    @Override
    public List<Barang> semuaBarang() {
        return tabelBarang.values().stream()
                .sorted(Comparator.comparing(Barang::kode))
                .toList();
    }

    @Override
    public void perbaruiStok(String kode, int stokBaru) {
        Barang barang = cariWajib(kode);
        tabelBarang.put(kode, barang.denganStok(stokBaru));
    }

    @Override
    public MutasiStok catat(MutasiStok mutasi) {
        MutasiStok tersimpan = new MutasiStok(idBerikutnya, mutasi.kodeBarang(),
                mutasi.jenis(), mutasi.jumlah(), mutasi.tanggal(),
                mutasi.petugas(), mutasi.catatan());
        idBerikutnya++;
        tabelMutasi.add(tersimpan);
        return tersimpan;
    }

    @Override
    public List<MutasiStok> riwayat() {
        return List.copyOf(tabelMutasi);
    }

    private Barang cariWajib(String kode) {
        Barang barang = tabelBarang.get(kode);
        if (barang == null) {
            throw new GudangException("kode barang tidak terdaftar: " + kode);
        }
        return barang;
    }
}

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 + "]";
    }
}

StokTidakCukupException.java

Unduh berkas
java
// Permintaan barang keluar melebihi stok yang tersedia saat itu.
public class StokTidakCukupException extends GudangException {

    public StokTidakCukupException(String pesan) {
        super(pesan);
    }
}