Penyimpanan memori punya satu kelemahan telak: JVM berhenti, seluruh data ikut selesai. Bagian 3 memberi gudang daya tahan lewat GudangRepositoryBerkas — implementasi kedua GudangRepository yang menuliskan kedua tabel ke satu berkas teks berpemisah ;. Karena layanan Gudang hanya mengenal interface, penggantian penyimpanan tidak menyentuh satu baris aturan bisnis pun — bukti pertama manfaat abstraksi. Tiga kemampuan operasional menyusul: pencarian kata kunci tidak peka huruf besar-kecil, filter barang yang perlu dipesan ulang, dan kartu stok per barang.

Penyimpanan Berkas yang Bertahan #

Satu berkas memuat dua “tabel”: baris BARANG; untuk keadaan terkini dan MUTASI; untuk jurnal, dibuka header versi GUDANG;1. Potongan berikut adalah isi berkas sungguhan hasil demo:

text
GUDANG;1
BARANG;BRG-001;Kertas HVS A4;RIM;47500;25;10
BARANG;BRG-002;Tinta Printer Hitam;BOTOL;185000;8;8
MUTASI;1;BRG-001;MASUK;50;2026-01-05;Budi;penerimaan awal
MUTASI;6;BRG-001;KELUAR;25;2026-01-15;Siti;permintaan divisi keuangan

Implementasinya mengikuti kontrak GudangRepository persis seperti versi memori, jadi Gudang tidak menyadari perbedaan apa pun:

import java.io.IOException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

// Penyimpanan berkas: data bertahan melewati akhir program. Satu berkas
// memuat dua "tabel": baris BARANG;... dan baris MUTASI;...
public final class GudangRepositoryBerkas implements GudangRepository {

    public static final String HEADER = "GUDANG;1";

    private final Path berkas;
    private final Map<String, Barang> tabelBarang = new LinkedHashMap<>();
    private final List<MutasiStok> tabelMutasi = new ArrayList<>();
    private int idBerikutnya = 1;

    /** Membuka penyimpanan berkas; isi lama dimuat bila berkasnya ada. */
    public GudangRepositoryBerkas(Path berkas) {
        this.berkas = berkas;
        if (Files.exists(berkas)) {
            muat();
        }
    }

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

    @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));
        tulis();
    }

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

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

    /** Menulis ulang seluruh isi ke berkas dengan charset UTF-8. */
    private void tulis() {
        List<String> baris = new ArrayList<>();
        baris.add(HEADER);
        for (Barang b : semuaBarang()) {
            baris.add("BARANG;" + b.kode() + ";" + b.nama() + ";" + b.satuan() + ";"
                    + b.harga().toPlainString() + ";" + b.stok() + ";" + b.stokMinimum());
        }
        for (MutasiStok m : tabelMutasi) {
            baris.add("MUTASI;" + m.id() + ";" + m.kodeBarang() + ";" + m.jenis() + ";"
                    + m.jumlah() + ";" + m.tanggal() + ";" + m.petugas() + ";" + m.catatan());
        }
        try {
            Files.write(berkas, baris, StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new GudangException("gagal menulis berkas gudang: " + berkas, e);
        }
    }

    /** Memuat isi berkas; baris cacat menghentikan pemuatan dengan nomor barisnya. */
    private void muat() {
        List<String> isi;
        try {
            isi = Files.readAllLines(berkas, StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new GudangException("gagal membaca berkas gudang: " + berkas, e);
        }
        if (isi.isEmpty() || !HEADER.equals(isi.get(0))) {
            throw new GudangException("header tidak dikenali pada berkas: " + berkas);
        }
        for (int i = 1; i < isi.size(); i++) {
            String baris = isi.get(i);
            try {
                if (baris.startsWith("BARANG;")) {
                    Barang b = parseBarang(baris);
                    tabelBarang.put(b.kode(), b);
                } else if (baris.startsWith("MUTASI;")) {
                    MutasiStok m = parseMutasi(baris);
                    tabelMutasi.add(m);
                    idBerikutnya = Math.max(idBerikutnya, m.id() + 1);
                } else {
                    throw new IllegalArgumentException("awalan baris tidak dikenali");
                }
            } catch (RuntimeException e) {
                throw new GudangException(
                        "baris " + (i + 1) + " tidak terbaca: " + e.getMessage(), e);
            }
        }
    }

    private static Barang parseBarang(String baris) {
        String[] kolom = baris.split(";", -1);
        if (kolom.length != 7) {
            throw new IllegalArgumentException("kolom barang " + kolom.length + ", harap 7");
        }
        BigDecimal harga;
        try {
            harga = new BigDecimal(kolom[4]);
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException("harga '" + kolom[4] + "' bukan angka");
        }
        return new Barang(kolom[1], kolom[2], kolom[3], harga,
                Integer.parseInt(kolom[5]), Integer.parseInt(kolom[6]));
    }

    private static MutasiStok parseMutasi(String baris) {
        String[] kolom = baris.split(";", -1);
        if (kolom.length != 8) {
            throw new IllegalArgumentException("kolom mutasi " + kolom.length + ", harap 8");
        }
        LocalDate tanggal;
        try {
            tanggal = LocalDate.parse(kolom[5]);
        } catch (DateTimeParseException e) {
            throw new IllegalArgumentException("tanggal '" + kolom[5] + "' tidak valid");
        }
        return new MutasiStok(Integer.parseInt(kolom[1]), kolom[2],
                JenisMutasi.valueOf(kolom[3]), Integer.parseInt(kolom[4]),
                tanggal, kolom[6], kolom[7]);
    }

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

    /** Kolom teks tidak boleh mengandung ';' karena bertabrakan dengan pemisah. */
    private static void tolakPemisah(String nilai, String kolom) {
        if (nilai.indexOf(';') >= 0) {
            throw new GudangException(kolom + " tidak boleh mengandung ';': " + nilai);
        }
    }
}

Empat keputusan penting di dalamnya. Constructor memuat isi lama bila berkas ada, sehingga membuka ulang berarti melanjutkan, bukan memulai dari nol. tulis() menulis ulang seluruh berkas setiap perubahan — sederhana dan cukup untuk data seukuran gudang kantor. muat() bersifat tegas: satu baris cacat menghentikan pemuatan dengan pesan yang menyebut nomor barisnya, karena isi berkas yang diragukan lebih baik ditolak utuh. split(";", -1) wajib memakai limit -1 agar kolom terakhir yang kosong (catatan mutasi tanpa keterangan) tidak hilang diam-diam.

Layanan yang Bertambah: Pencarian dan Filter #

Dua tambahan kecil menempel pada model dan layanan: Barang.perluRestok() mengemas aturan pemesanan ulang, dan Gudang mendapat tiga query operasional:

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

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

    // Barang perlu dipesan ulang bila stoknya menyentuh batas minimum.
    public boolean perluRestok() {
        return stok <= 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 + "]";
    }
}
java Gudang.java
import java.time.LocalDate;
import java.util.List;
import java.util.Locale;

// 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();
    }

    /** Pencarian kata kunci pada kode dan nama, tidak peka huruf besar-kecil. */
    public List<Barang> cariKataKunci(String kataKunci) {
        String kunci = kataKunci.toLowerCase(Locale.ROOT);
        return repository.semuaBarang().stream()
                .filter(b -> b.nama().toLowerCase(Locale.ROOT).contains(kunci)
                        || b.kode().toLowerCase(Locale.ROOT).contains(kunci))
                .toList();
    }

    /** Barang yang stoknya menyentuh batas minimum atau sudah lewat. */
    public List<Barang> barangPerluRestok() {
        return repository.semuaBarang().stream()
                .filter(Barang::perluRestok)
                .toList();
    }

    /** Seluruh mutasi satu barang (kartu stok), dalam urutan pencatatan. */
    public List<MutasiStok> riwayatBarang(String kode) {
        cariWajib(kode);
        return repository.riwayat().stream()
                .filter(m -> m.kodeBarang().equals(kode))
                .toList();
    }

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

Perhatikan toLowerCase(Locale.ROOT) pada cariKataKunci. Method toLowerCase() tanpa argumen memakai locale bawaan JVM; pada locale Turki, huruf besar I diubah menjadi ı tanpa titik sehingga pencarian "KERTAS" tiba-tiba tidak lagi cocok dengan "kertas". Locale.ROOT adalah locale netral yang perilakunya sama di semua mesin — syarat determinisme. Ketiga query memakai stream() berantai filter dan toList(); hasilnya List tak dapat diubah sehingga pemanggil tak bisa menyelipkan barang palsu.

Demo: Dua Sesi, Satu Berkas #

Demo menjalankan satu sesi operasional, membuka ulang berkas lewat instance baru, lalu membuktikan data hidup kembali — termasuk id mutasi yang melanjutkan dari 8 ke 9:

java DemoGudang.java
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.Locale;

// Bagian 3: penyimpanan berkas, pencarian kata kunci, dan filter stok menipis.
public class DemoGudang {

    public static void main(String[] args) throws IOException {
        Path berkas = Path.of("gudang.txt");
        Files.deleteIfExists(berkas);   // mulai dari bersih agar demo dapat diulang

        // 1. Satu sesi operasional lengkap dengan penyimpanan berkas.
        Gudang gudang = new Gudang(new GudangRepositoryBerkas(berkas));
        isiData(gudang);
        gudang.barangMasuk("BRG-001", 50, LocalDate.of(2026, 1, 5), "Budi", "penerimaan awal");
        gudang.barangMasuk("BRG-002", 10, LocalDate.of(2026, 1, 5), "Budi", "penerimaan awal");
        gudang.barangMasuk("BRG-003", 20, LocalDate.of(2026, 1, 6), "Dewi", "penerimaan awal");
        gudang.barangMasuk("BRG-004", 12, LocalDate.of(2026, 1, 6), "Dewi", "penerimaan awal");
        gudang.barangMasuk("BRG-005", 30, LocalDate.of(2026, 1, 7), "Dewi", "penerimaan awal");
        gudang.barangKeluar("BRG-001", 25, LocalDate.of(2026, 1, 15), "Siti",
                "permintaan divisi keuangan");
        gudang.barangKeluar("BRG-002", 8, LocalDate.of(2026, 1, 20), "Siti",
                "permintaan divisi pemasaran");
        gudang.barangKeluar("BRG-003", 17, LocalDate.of(2026, 1, 22), "Siti",
                "permintaan divisi keuangan");

        System.out.println("== Tabel barang (sesi pertama) ==");
        for (Barang b : gudang.semuaBarang()) {
            System.out.printf(Locale.US, "%-8s %-22s %-6s %6d%n",
                    b.kode(), b.nama(), b.satuan(), b.stok());
        }

        // 2. Buka ulang berkas yang sama: data bertahan melewati akhir program.
        Gudang dibuka = new Gudang(new GudangRepositoryBerkas(berkas));
        System.out.println();
        System.out.println("setelah dibuka ulang : " + dibuka.semuaBarang().size()
                + " barang, " + dibuka.riwayat().size() + " mutasi");
        System.out.println("stok BRG-002 bertahan: " + dibuka.cariBarang("BRG-002").stok()
                + " BOTOL");
        MutasiStok lanjutan = dibuka.barangMasuk("BRG-002", 6,
                LocalDate.of(2026, 1, 25), "Budi", "pembelian rutin");
        System.out.println("mutasi baru          : " + lanjutan);

        // 3. Pencarian kata kunci: tidak peka huruf besar-kecil.
        System.out.println();
        System.out.println("== cari \"kertas\" ==");
        for (Barang b : dibuka.cariKataKunci("kertas")) {
            System.out.println("hasil : " + b.nama() + " (" + b.stok() + " " + b.satuan() + ")");
        }
        System.out.println("== cari \"KERTAS\" ==");
        System.out.println("hasil : " + dibuka.cariKataKunci("KERTAS").size() + " barang");

        // 4. Filter barang yang perlu dipesan ulang.
        System.out.println();
        System.out.println("== barang perlu restok ==");
        for (Barang b : dibuka.barangPerluRestok()) {
            System.out.printf(Locale.US, "hasil : %-22s stok %d, minimum %d%n",
                    b.nama(), b.stok(), b.stokMinimum());
        }

        // 5. Kartu stok: seluruh mutasi satu barang dari awal.
        System.out.println();
        System.out.println("== kartu stok BRG-002 ==");
        for (MutasiStok m : dibuka.riwayatBarang("BRG-002")) {
            System.out.printf(Locale.US, "#%-2d %s %-6s %3d  %s%n",
                    m.id(), m.tanggal(), m.jenis(), m.jumlah(), m.petugas());
        }
    }

    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, 8));
        gudang.daftar(new Barang("BRG-003", "Kertas HVS F4", "RIM",
                new BigDecimal("52000"), 0, 8));
        gudang.daftar(new Barang("BRG-004", "Pulpen Gel Hitam 0.5", "BOX",
                new BigDecimal("62000"), 0, 5));
        gudang.daftar(new Barang("BRG-005", "Amplop Cokelat Folio", "PAK",
                new BigDecimal("15500"), 0, 10));
    }
}

Output yang Dihasilkan:

text
== Tabel barang (sesi pertama) ==
BRG-001  Kertas HVS A4          RIM        25
BRG-002  Tinta Printer Hitam    BOTOL       2
BRG-003  Kertas HVS F4          RIM         3
BRG-004  Pulpen Gel Hitam 0.5   BOX        12
BRG-005  Amplop Cokelat Folio   PAK        30

setelah dibuka ulang : 5 barang, 8 mutasi
stok BRG-002 bertahan: 2 BOTOL
mutasi baru          : MutasiStok[id=9, kode=BRG-002, MASUK 6, tanggal=2026-01-25, petugas=Budi, catatan=pembelian rutin]

== cari "kertas" ==
hasil : Kertas HVS A4 (25 RIM)
hasil : Kertas HVS F4 (3 RIM)
== cari "KERTAS" ==
hasil : 2 barang

== barang perlu restok ==
hasil : Tinta Printer Hitam    stok 8, minimum 8
hasil : Kertas HVS F4          stok 3, minimum 8

== kartu stok BRG-002 ==
#2  2026-01-05 MASUK   10  Budi
#7  2026-01-20 KELUAR   8  Siti
#9  2026-01-25 MASUK    6  Budi

Mari kita baca keluarannya:

  1. Sesi pertama: lima barang terisi oleh lima penerimaan; tiga permintaan menurunkan BRG-001 ke 25, BRG-002 ke 2, dan BRG-003 ke 3.
  2. setelah dibuka ulang : 5 barang, 8 mutasi: instance kedua membaca berkas yang sama tanpa satu baris inisialisasi ulang. mutasi baru ber-id 9 membuktikan counter identitas dipulihkan dari baris terakhir jurnal.
  3. cari "kertas" dan cari "KERTAS": keduanya menghasilkan dua barang yang sama — pencarian tidak peka huruf besar-kecil, diuji dari dua arah.
  4. barang perlu restok: Tinta Printer Hitam tepat menyentuh minimum 8 setelah ditaruh 6 botol, dan Kertas HVS F4 tersisa 3 dari minimum 8.
  5. kartu stok BRG-002: tiga baris jurnal untuk satu barang, terurut id 2 → 7 → 9 — riwayat yang melintasi dua sesi terbaca sebagai satu kesatuan.

Pencarian di Sisi Database #

Versi memori memfilter di Java setelah seluruh baris terbaca. Pada database, filter didorong ke sisi penyimpanan lewat klausa WHERE — baris tak relevan tidak pernah menyeberangi koneksi dan indeks bisa bekerja. Kode berikut tetap berstatus rancangan karena lingkungan artikel ini tanpa driver JDBC:

java
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;

// Pencarian dengan PreparedStatement: parameter diikat, tidak digabung ke teks SQL.
List<Barang> cariKataKunci(Connection koneksi, String kataKunci) throws SQLException {
    String pola = "%" + kataKunci.toLowerCase(Locale.ROOT) + "%";
    String sql = "SELECT kode, nama, satuan, harga, stok, stok_minimum "
            + "FROM barang WHERE LOWER(nama) LIKE ? ORDER BY kode";
    List<Barang> hasil = new ArrayList<>();
    try (PreparedStatement ps = koneksi.prepareStatement(sql)) {
        ps.setString(1, pola);            // parameter diikat sebagai nilai
        try (ResultSet rs = ps.executeQuery()) {
            while (rs.next()) {
                hasil.add(new Barang(
                        rs.getString("kode"),
                        rs.getString("nama"),
                        rs.getString("satuan"),
                        rs.getBigDecimal("harga"),
                        rs.getInt("stok"),
                        rs.getInt("stok_minimum")));
            }
        }
    }
    return hasil;
}

Tanda % berarti “teks apa pun” di awal dan akhir, sehingga pola %kertas% cocok di mana pun kata itu muncul dalam nama. LOWER(nama) LIKE ? memindahkan sifat tidak peka huruf ke sisi SQL, selaras dengan Locale.ROOT di sisi Java. Filter stok menipis lebih sederhana lagi karena seluruh logikanya satu klausa:

sql
SELECT kode, nama, satuan, stok, stok_minimum
FROM barang
WHERE stok <= stok_minimum
ORDER BY kode;

Data kini bertahan dan temu-baliknya cepat. Satu kebutuhan tersisa: pegawai gudang butuh laporan yang terbuka di aplikasi lain. Bagian 4 menutup proyek dengan ekspor CSV dan harness pengujian atas seluruh aturan stok.

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

    // Barang perlu dipesan ulang bila stoknya menyentuh batas minimum.
    public boolean perluRestok() {
        return stok <= 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.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.Locale;

// Bagian 3: penyimpanan berkas, pencarian kata kunci, dan filter stok menipis.
public class DemoGudang {

    public static void main(String[] args) throws IOException {
        Path berkas = Path.of("gudang.txt");
        Files.deleteIfExists(berkas);   // mulai dari bersih agar demo dapat diulang

        // 1. Satu sesi operasional lengkap dengan penyimpanan berkas.
        Gudang gudang = new Gudang(new GudangRepositoryBerkas(berkas));
        isiData(gudang);
        gudang.barangMasuk("BRG-001", 50, LocalDate.of(2026, 1, 5), "Budi", "penerimaan awal");
        gudang.barangMasuk("BRG-002", 10, LocalDate.of(2026, 1, 5), "Budi", "penerimaan awal");
        gudang.barangMasuk("BRG-003", 20, LocalDate.of(2026, 1, 6), "Dewi", "penerimaan awal");
        gudang.barangMasuk("BRG-004", 12, LocalDate.of(2026, 1, 6), "Dewi", "penerimaan awal");
        gudang.barangMasuk("BRG-005", 30, LocalDate.of(2026, 1, 7), "Dewi", "penerimaan awal");
        gudang.barangKeluar("BRG-001", 25, LocalDate.of(2026, 1, 15), "Siti",
                "permintaan divisi keuangan");
        gudang.barangKeluar("BRG-002", 8, LocalDate.of(2026, 1, 20), "Siti",
                "permintaan divisi pemasaran");
        gudang.barangKeluar("BRG-003", 17, LocalDate.of(2026, 1, 22), "Siti",
                "permintaan divisi keuangan");

        System.out.println("== Tabel barang (sesi pertama) ==");
        for (Barang b : gudang.semuaBarang()) {
            System.out.printf(Locale.US, "%-8s %-22s %-6s %6d%n",
                    b.kode(), b.nama(), b.satuan(), b.stok());
        }

        // 2. Buka ulang berkas yang sama: data bertahan melewati akhir program.
        Gudang dibuka = new Gudang(new GudangRepositoryBerkas(berkas));
        System.out.println();
        System.out.println("setelah dibuka ulang : " + dibuka.semuaBarang().size()
                + " barang, " + dibuka.riwayat().size() + " mutasi");
        System.out.println("stok BRG-002 bertahan: " + dibuka.cariBarang("BRG-002").stok()
                + " BOTOL");
        MutasiStok lanjutan = dibuka.barangMasuk("BRG-002", 6,
                LocalDate.of(2026, 1, 25), "Budi", "pembelian rutin");
        System.out.println("mutasi baru          : " + lanjutan);

        // 3. Pencarian kata kunci: tidak peka huruf besar-kecil.
        System.out.println();
        System.out.println("== cari \"kertas\" ==");
        for (Barang b : dibuka.cariKataKunci("kertas")) {
            System.out.println("hasil : " + b.nama() + " (" + b.stok() + " " + b.satuan() + ")");
        }
        System.out.println("== cari \"KERTAS\" ==");
        System.out.println("hasil : " + dibuka.cariKataKunci("KERTAS").size() + " barang");

        // 4. Filter barang yang perlu dipesan ulang.
        System.out.println();
        System.out.println("== barang perlu restok ==");
        for (Barang b : dibuka.barangPerluRestok()) {
            System.out.printf(Locale.US, "hasil : %-22s stok %d, minimum %d%n",
                    b.nama(), b.stok(), b.stokMinimum());
        }

        // 5. Kartu stok: seluruh mutasi satu barang dari awal.
        System.out.println();
        System.out.println("== kartu stok BRG-002 ==");
        for (MutasiStok m : dibuka.riwayatBarang("BRG-002")) {
            System.out.printf(Locale.US, "#%-2d %s %-6s %3d  %s%n",
                    m.id(), m.tanggal(), m.jenis(), m.jumlah(), m.petugas());
        }
    }

    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, 8));
        gudang.daftar(new Barang("BRG-003", "Kertas HVS F4", "RIM",
                new BigDecimal("52000"), 0, 8));
        gudang.daftar(new Barang("BRG-004", "Pulpen Gel Hitam 0.5", "BOX",
                new BigDecimal("62000"), 0, 5));
        gudang.daftar(new Barang("BRG-005", "Amplop Cokelat Folio", "PAK",
                new BigDecimal("15500"), 0, 10));
    }
}

Gudang.java

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

// 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();
    }

    /** Pencarian kata kunci pada kode dan nama, tidak peka huruf besar-kecil. */
    public List<Barang> cariKataKunci(String kataKunci) {
        String kunci = kataKunci.toLowerCase(Locale.ROOT);
        return repository.semuaBarang().stream()
                .filter(b -> b.nama().toLowerCase(Locale.ROOT).contains(kunci)
                        || b.kode().toLowerCase(Locale.ROOT).contains(kunci))
                .toList();
    }

    /** Barang yang stoknya menyentuh batas minimum atau sudah lewat. */
    public List<Barang> barangPerluRestok() {
        return repository.semuaBarang().stream()
                .filter(Barang::perluRestok)
                .toList();
    }

    /** Seluruh mutasi satu barang (kartu stok), dalam urutan pencatatan. */
    public List<MutasiStok> riwayatBarang(String kode) {
        cariWajib(kode);
        return repository.riwayat().stream()
                .filter(m -> m.kodeBarang().equals(kode))
                .toList();
    }

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

GudangRepositoryBerkas.java

Unduh berkas
java
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

// Penyimpanan berkas: data bertahan melewati akhir program. Satu berkas
// memuat dua "tabel": baris BARANG;... dan baris MUTASI;...
public final class GudangRepositoryBerkas implements GudangRepository {

    public static final String HEADER = "GUDANG;1";

    private final Path berkas;
    private final Map<String, Barang> tabelBarang = new LinkedHashMap<>();
    private final List<MutasiStok> tabelMutasi = new ArrayList<>();
    private int idBerikutnya = 1;

    /** Membuka penyimpanan berkas; isi lama dimuat bila berkasnya ada. */
    public GudangRepositoryBerkas(Path berkas) {
        this.berkas = berkas;
        if (Files.exists(berkas)) {
            muat();
        }
    }

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

    @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));
        tulis();
    }

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

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

    /** Menulis ulang seluruh isi ke berkas dengan charset UTF-8. */
    private void tulis() {
        List<String> baris = new ArrayList<>();
        baris.add(HEADER);
        for (Barang b : semuaBarang()) {
            baris.add("BARANG;" + b.kode() + ";" + b.nama() + ";" + b.satuan() + ";"
                    + b.harga().toPlainString() + ";" + b.stok() + ";" + b.stokMinimum());
        }
        for (MutasiStok m : tabelMutasi) {
            baris.add("MUTASI;" + m.id() + ";" + m.kodeBarang() + ";" + m.jenis() + ";"
                    + m.jumlah() + ";" + m.tanggal() + ";" + m.petugas() + ";" + m.catatan());
        }
        try {
            Files.write(berkas, baris, StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new GudangException("gagal menulis berkas gudang: " + berkas, e);
        }
    }

    /** Memuat isi berkas; baris cacat menghentikan pemuatan dengan nomor barisnya. */
    private void muat() {
        List<String> isi;
        try {
            isi = Files.readAllLines(berkas, StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new GudangException("gagal membaca berkas gudang: " + berkas, e);
        }
        if (isi.isEmpty() || !HEADER.equals(isi.get(0))) {
            throw new GudangException("header tidak dikenali pada berkas: " + berkas);
        }
        for (int i = 1; i < isi.size(); i++) {
            String baris = isi.get(i);
            try {
                if (baris.startsWith("BARANG;")) {
                    Barang b = parseBarang(baris);
                    tabelBarang.put(b.kode(), b);
                } else if (baris.startsWith("MUTASI;")) {
                    MutasiStok m = parseMutasi(baris);
                    tabelMutasi.add(m);
                    idBerikutnya = Math.max(idBerikutnya, m.id() + 1);
                } else {
                    throw new IllegalArgumentException("awalan baris tidak dikenali");
                }
            } catch (RuntimeException e) {
                throw new GudangException(
                        "baris " + (i + 1) + " tidak terbaca: " + e.getMessage(), e);
            }
        }
    }

    private static Barang parseBarang(String baris) {
        String[] kolom = baris.split(";", -1);
        if (kolom.length != 7) {
            throw new IllegalArgumentException("kolom barang " + kolom.length + ", harap 7");
        }
        BigDecimal harga;
        try {
            harga = new BigDecimal(kolom[4]);
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException("harga '" + kolom[4] + "' bukan angka");
        }
        return new Barang(kolom[1], kolom[2], kolom[3], harga,
                Integer.parseInt(kolom[5]), Integer.parseInt(kolom[6]));
    }

    private static MutasiStok parseMutasi(String baris) {
        String[] kolom = baris.split(";", -1);
        if (kolom.length != 8) {
            throw new IllegalArgumentException("kolom mutasi " + kolom.length + ", harap 8");
        }
        LocalDate tanggal;
        try {
            tanggal = LocalDate.parse(kolom[5]);
        } catch (DateTimeParseException e) {
            throw new IllegalArgumentException("tanggal '" + kolom[5] + "' tidak valid");
        }
        return new MutasiStok(Integer.parseInt(kolom[1]), kolom[2],
                JenisMutasi.valueOf(kolom[3]), Integer.parseInt(kolom[4]),
                tanggal, kolom[6], kolom[7]);
    }

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

    /** Kolom teks tidak boleh mengandung ';' karena bertabrakan dengan pemisah. */
    private static void tolakPemisah(String nilai, String kolom) {
        if (nilai.indexOf(';') >= 0) {
            throw new GudangException(kolom + " tidak boleh mengandung ';': " + nilai);
        }
    }
}

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