Aplikasi inventaris kini lengkap sebagai sistem: barang terdaftar, stok bergerak dengan aturan, data bertahan di berkas, dan pencarian bekerja. Dua pekerjaan terakhir bersifat pembuktian. Pertama, laporan untuk pihak luar: tabel barang dan jurnal mutasi diekspor ke berkas CSV yang terbuka di aplikasi lembar kerja mana pun — lengkap dengan aturan pelolosan nilai agar koma di dalam catatan tidak merusak kolom. Kedua, pembuktian bagi kita sendiri: harness pengujian berbasis main menjalankan 24 pemeriksaan atas seluruh aturan stok, lalu ditutup padanan JUnit 5 sebagai rancangan.

Format CSV dan Pelolosan Nilai #

CSV terlihat sederhana — pisahkan kolom dengan koma — sampai satu nilai berisi koma itu sendiri. Aturan penanganannya distandarkan RFC 4180: nilai yang memuat koma, kutip ganda, atau baris baru dibungkus kutip ganda, dan setiap kutip asli di dalamnya digandakan. Kelas LaporanCsv menerapkannya pada method kecil csv(String):

java LaporanCsv.java
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

// Ekspor laporan CSV: baris pertama nama kolom, pemisah koma, nilai yang
// memuat karakter khusus dibungkus kutip ganda sesuai RFC 4180.
public final class LaporanCsv {

    private LaporanCsv() {
        // Kelas utilitas: seluruh anggota static, tidak perlu instance.
    }

    /** Laporan tabel barang: kode, nama, satuan, harga, stok, stok_minimum. */
    public static void eksporBarang(List<Barang> barang, Path berkas) {
        List<String> baris = new ArrayList<>();
        baris.add("kode,nama,satuan,harga,stok,stok_minimum");
        for (Barang b : barang) {
            baris.add(csv(b.kode()) + "," + csv(b.nama()) + "," + csv(b.satuan())
                    + "," + b.harga().toPlainString() + "," + b.stok() + ","
                    + b.stokMinimum());
        }
        tulis(berkas, baris);
    }

    /** Laporan jurnal mutasi: id, tanggal, kode_barang, jenis, jumlah, petugas, catatan. */
    public static void eksporMutasi(List<MutasiStok> mutasi, Path berkas) {
        List<String> baris = new ArrayList<>();
        baris.add("id,tanggal,kode_barang,jenis,jumlah,petugas,catatan");
        for (MutasiStok m : mutasi) {
            baris.add(m.id() + "," + m.tanggal() + "," + csv(m.kodeBarang()) + ","
                    + m.jenis() + "," + m.jumlah() + "," + csv(m.petugas()) + ","
                    + csv(m.catatan()));
        }
        tulis(berkas, baris);
    }

    /**
     * Meloloskan satu nilai CSV: bila nilai memuat koma, kutip ganda, atau
     * baris baru, seluruh nilai dibungkus kutip ganda dan setiap kutip asli
     * digandakan (" menjadi "").
     */
    static String csv(String nilai) {
        if (nilai.indexOf(',') >= 0 || nilai.indexOf('"') >= 0
                || nilai.indexOf('\n') >= 0) {
            return '"' + nilai.replace("\"", "\"\"") + '"';
        }
        return nilai;
    }

    private static void tulis(Path berkas, List<String> baris) {
        try {
            Files.write(berkas, baris, StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new GudangException("gagal menulis laporan: " + berkas, e);
        }
    }
}

Method csv adalah kunci seluruh berkas: eksporBarang dan eksporMutasi hanya merangkai kolom dengan csv(...) di antaranya, dan angka ditulis lewat toPlainString() agar BigDecimal tidak memilih notasi ilmiah. Dua laporan itu perannya berbeda: potret keadaan untuk manajemen dan bukti kejadian untuk audit — pasangan tabel yang sejak Bagian 1 memang dipisahkan untuk keperluan ini.

Demo: Dua Laporan Sekaligus #

Demo mengisi gudang dengan data Bagian 3, menambahkan satu catatan yang mengandung koma sekaligus kutip ganda — kasus terburuk bagi CSV — lalu menuliskan kedua laporan dan mencetak isinya kembali:

java DemoGudang.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.util.Locale;

// Bagian 4: ekspor laporan CSV untuk tabel barang dan jurnal mutasi.
public class DemoGudang {

    public static void main(String[] args) throws IOException {
        Gudang gudang = new Gudang(new GudangRepositoryMemori());
        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",
                "koreksi hasil \"stok opname\", selisih 8");
        gudang.barangKeluar("BRG-003", 17, LocalDate.of(2026, 1, 22), "Siti",
                "permintaan divisi keuangan");

        // 1. Laporan tabel barang.
        Path berkasBarang = Path.of("laporan-barang.csv");
        LaporanCsv.eksporBarang(gudang.semuaBarang(), berkasBarang);
        System.out.println("== laporan-barang.csv ==");
        System.out.print(Files.readString(berkasBarang, StandardCharsets.UTF_8));
        System.out.println();

        // 2. Laporan jurnal mutasi, termasuk catatan yang memuat koma dan kutip.
        Path berkasMutasi = Path.of("laporan-mutasi.csv");
        LaporanCsv.eksporMutasi(gudang.riwayat(), berkasMutasi);
        System.out.println("== laporan-mutasi.csv ==");
        System.out.print(Files.readString(berkasMutasi, StandardCharsets.UTF_8));
        System.out.println();

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

    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
== laporan-barang.csv ==
kode,nama,satuan,harga,stok,stok_minimum
BRG-001,Kertas HVS A4,RIM,47500,25,10
BRG-002,Tinta Printer Hitam,BOTOL,185000,2,8
BRG-003,Kertas HVS F4,RIM,52000,3,8
BRG-004,Pulpen Gel Hitam 0.5,BOX,62000,12,5
BRG-005,Amplop Cokelat Folio,PAK,15500,30,10

== laporan-mutasi.csv ==
id,tanggal,kode_barang,jenis,jumlah,petugas,catatan
1,2026-01-05,BRG-001,MASUK,50,Budi,penerimaan awal
2,2026-01-05,BRG-002,MASUK,10,Budi,penerimaan awal
3,2026-01-06,BRG-003,MASUK,20,Dewi,penerimaan awal
4,2026-01-06,BRG-004,MASUK,12,Dewi,penerimaan awal
5,2026-01-07,BRG-005,MASUK,30,Dewi,penerimaan awal
6,2026-01-15,BRG-001,KELUAR,25,Siti,permintaan divisi keuangan
7,2026-01-20,BRG-002,KELUAR,8,Siti,"koreksi hasil ""stok opname"", selisih 8"
8,2026-01-22,BRG-003,KELUAR,17,Siti,permintaan divisi keuangan

== perlu restok ==
Tinta Printer Hitam    stok 2, minimum 8
Kertas HVS F4          stok 3, minimum 8

Mari kita baca keluarannya:

  1. laporan-barang.csv: lima baris data di bawah satu header; seluruh nilai polos sehingga tidak ada yang perlu dibungkus — kolomnya tepat enam per baris.
  2. Baris id 7 pada laporan-mutasi.csv: catatan koreksi hasil "stok opname", selisih 8 tercetak sebagai "koreksi hasil ""stok opname"", selisih 8" — dibungkus kutip dan setiap kutip aslinya digandakan, sehingga pembaca CSV tetap melihat tujuh kolom, bukan delapan.
  3. == perlu restok ==: dua barang menyentuh batas minimum dan masuk daftar pembelian untuk manajemen.

Harness Pengujian Berbasis main #

Aplikasi murni JDK tidak butuh pustaka pengujian untuk menyatakan benar atau salah. Pola cek(nama, kondisi) menghitung PASS/FAIL, exception yang diharapkan ditangkap dengan try/catch, dan exit code 1 memberi sinyal kegagalan. Dua puluh empat pemeriksaan menutup seluruh aturan proyek:

java UjiGudang.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.util.List;

// Harness pengujian berbasis main: PASS/FAIL tanpa pustaka eksternal.
// Exit code 1 bila ada kegagalan — sinyal bagi proses pemanggil.
public class UjiGudang {

    private static int lulus = 0;
    private static int gagal = 0;

    public static void main(String[] args) throws IOException {
        ujiPendaftaran();
        ujiMutasiMenaikkanDanMenurunkan();
        ujiStokTidakCukup();
        ujiBarangTidakDitemukan();
        ujiJumlahTidakPositif();
        ujiMutasiTercatatUrut();
        ujiJurnalSelarasStok();
        ujiPencarian();
        ujiFilterRestok();
        ujiPersistensiBerkas();
        ujiPelolosanCsv();
        System.out.println();
        System.out.println("Ringkasan: " + lulus + " lulus, " + gagal + " gagal");
        if (gagal > 0) {
            System.exit(1);
        }
    }

    /** Satu pernyataan uji: catat PASS bila kondisi benar, FAIL bila salah. */
    private static void cek(String nama, boolean kondisi) {
        if (kondisi) {
            lulus++;
            System.out.println("PASS: " + nama);
        } else {
            gagal++;
            System.out.println("FAIL: " + nama);
        }
    }

    /** Gudang segil berisi satu barang uji. */
    private static Gudang gudangSegil() {
        Gudang gudang = new Gudang(new GudangRepositoryMemori());
        gudang.daftar(new Barang("BRG-001", "Kertas HVS A4", "RIM",
                new BigDecimal("47500"), 0, 10));
        return gudang;
    }

    /** Gudang lima barang dengan mutasi campuran untuk uji pencarian dan filter. */
    private static Gudang gudangLimaBarang() {
        Gudang gudang = new Gudang(new GudangRepositoryMemori());
        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.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-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");
        return gudang;
    }

    private static void ujiPendaftaran() {
        Gudang gudang = gudangSegil();
        cek("barang terdaftar ditemukan lewat kode",
                gudang.cariBarang("BRG-001").nama().equals("Kertas HVS A4"));
        try {
            gudang.daftar(new Barang("BRG-001", "Kertas HVS A4", "RIM",
                    new BigDecimal("47500"), 0, 10));
            cek("kode ganda ditolak", false);
        } catch (GudangException e) {
            cek("kode ganda ditolak", e.getMessage().contains("sudah terdaftar"));
        }
    }

    private static void ujiMutasiMenaikkanDanMenurunkan() {
        Gudang gudang = gudangSegil();
        gudang.barangMasuk("BRG-001", 50, LocalDate.of(2026, 1, 5), "Budi", "penerimaan awal");
        gudang.barangKeluar("BRG-001", 20, LocalDate.of(2026, 1, 8), "Siti", "permintaan");
        cek("masuk 50 keluar 20 menyisakan stok 30",
                gudang.cariBarang("BRG-001").stok() == 30);
    }

    /** Aturan inti: stok tidak boleh minus, penolakan tidak meninggalkan jejak. */
    private static void ujiStokTidakCukup() {
        Gudang gudang = gudangSegil();
        gudang.barangMasuk("BRG-001", 10, LocalDate.of(2026, 1, 5), "Budi", "penerimaan awal");
        int stokSebelum = gudang.cariBarang("BRG-001").stok();
        int mutasiSebelum = gudang.riwayat().size();
        try {
            gudang.barangKeluar("BRG-001", 11, LocalDate.of(2026, 1, 6), "Siti", "permintaan");
            cek("keluar melebihi stok melempar StokTidakCukupException", false);
        } catch (StokTidakCukupException e) {
            cek("keluar melebihi stok melempar StokTidakCukupException",
                    e.getMessage().contains("tersisa 10"));
        }
        cek("stok tidak berubah setelah penolakan",
                gudang.cariBarang("BRG-001").stok() == stokSebelum);
        cek("tidak ada jurnal untuk transaksi yang ditolak",
                gudang.riwayat().size() == mutasiSebelum);
    }

    private static void ujiBarangTidakDitemukan() {
        Gudang gudang = gudangSegil();
        try {
            gudang.barangMasuk("BRG-009", 5, LocalDate.of(2026, 1, 5), "Budi", "uji");
            cek("kode tak dikenal melempar BarangTidakDitemukanException", false);
        } catch (BarangTidakDitemukanException e) {
            cek("kode tak dikenal melempar BarangTidakDitemukanException",
                    e.getMessage().contains("BRG-009"));
        }
    }

    private static void ujiJumlahTidakPositif() {
        Gudang gudang = gudangSegil();
        gudang.barangMasuk("BRG-001", 10, LocalDate.of(2026, 1, 5), "Budi", "penerimaan awal");
        try {
            gudang.barangMasuk("BRG-001", 0, LocalDate.of(2026, 1, 6), "Budi", "uji");
            cek("jumlah nol ditolak", false);
        } catch (GudangException e) {
            cek("jumlah nol ditolak", e.getMessage().contains("positif"));
        }
    }

    private static void ujiMutasiTercatatUrut() {
        Gudang gudang = gudangSegil();
        gudang.barangMasuk("BRG-001", 10, LocalDate.of(2026, 1, 5), "Budi", "pertama");
        gudang.barangMasuk("BRG-001", 5, LocalDate.of(2026, 1, 6), "Siti", "kedua");
        gudang.barangKeluar("BRG-001", 3, LocalDate.of(2026, 1, 7), "Dewi", "ketiga");
        List<MutasiStok> riwayat = gudang.riwayat();
        cek("riwayat tercatat urut dengan id 1, 2, 3",
                riwayat.get(0).id() == 1 && riwayat.get(1).id() == 2
                        && riwayat.get(2).id() == 3);
        cek("urutan riwayat mengikuti urutan pemanggilan",
                riwayat.get(2).catatan().equals("ketiga"));
    }

    /** Invarian Bagian 1: stok selalu sama dengan penjumlahan seluruh jurnal. */
    private static void ujiJurnalSelarasStok() {
        Gudang gudang = gudangSegil();
        gudang.barangMasuk("BRG-001", 50, LocalDate.of(2026, 1, 5), "Budi", "awal");
        gudang.barangKeluar("BRG-001", 20, LocalDate.of(2026, 1, 8), "Siti", "permintaan");
        gudang.barangMasuk("BRG-001", 7, LocalDate.of(2026, 1, 10), "Dewi", "koreksi");
        int dariJurnal = 0;
        for (MutasiStok m : gudang.riwayat()) {
            dariJurnal += m.jenis().tanda() * m.jumlah();
        }
        cek("stok selalu sama dengan penjumlahan jurnal",
                gudang.cariBarang("BRG-001").stok() == dariJurnal);
    }

    private static void ujiPencarian() {
        Gudang gudang = gudangLimaBarang();
        cek("pencarian huruf kecil menemukan 2 kertas",
                gudang.cariKataKunci("kertas").size() == 2);
        cek("pencarian huruf besar memberi hasil identik",
                gudang.cariKataKunci("KERTAS").equals(gudang.cariKataKunci("kertas")));
        cek("kata tanpa padanan menghasilkan daftar kosong",
                gudang.cariKataKunci("kursi").isEmpty());
    }

    private static void ujiFilterRestok() {
        Gudang gudang = gudangLimaBarang();
        List<Barang> menipis = gudang.barangPerluRestok();
        cek("filter restok menemukan 2 barang", menipis.size() == 2);
        cek("filter restok terurut naik berdasarkan kode",
                menipis.get(0).kode().equals("BRG-002")
                        && menipis.get(1).kode().equals("BRG-003"));
    }

    private static void ujiPersistensiBerkas() throws IOException {
        Path berkas = Path.of("uji-gudang.txt");
        Files.deleteIfExists(berkas);   // mulai bersih agar uji dapat diulang

        Gudang pertama = new Gudang(new GudangRepositoryBerkas(berkas));
        pertama.daftar(new Barang("BRG-001", "Kertas HVS A4", "RIM",
                new BigDecimal("47500"), 0, 10));
        pertama.barangMasuk("BRG-001", 50, LocalDate.of(2026, 1, 5), "Budi", "penerimaan awal");
        pertama.barangKeluar("BRG-001", 20, LocalDate.of(2026, 1, 8), "Siti", "permintaan");

        Gudang kedua = new Gudang(new GudangRepositoryBerkas(berkas));
        cek("berkas memuat kembali barang yang sama",
                kedua.cariBarang("BRG-001").equals(pertama.cariBarang("BRG-001")));
        cek("berkas memuat kembali seluruh jurnal",
                kedua.riwayat().equals(pertama.riwayat()));
        MutasiStok lanjutan = kedua.barangMasuk("BRG-001", 5,
                LocalDate.of(2026, 1, 25), "Dewi", "pembelian rutin");
        cek("id mutasi berlanjut setelah dibuka ulang", lanjutan.id() == 3);
    }

    private static void ujiPelolosanCsv() throws IOException {
        cek("nilai polos tidak dibungkus",
                LaporanCsv.csv("Kertas HVS A4").equals("Kertas HVS A4"));
        cek("nilai berkoma dibungkus kutip ganda",
                LaporanCsv.csv("a,b").equals("\"a,b\""));
        cek("kutip asli digandakan di dalam pembungkus",
                LaporanCsv.csv("kata \"x\"").equals("\"kata \"\"x\"\"\""));

        Gudang gudang = gudangSegil();
        gudang.barangMasuk("BRG-001", 10, LocalDate.of(2026, 1, 5), "Budi", "penerimaan awal");
        gudang.barangKeluar("BRG-001", 2, LocalDate.of(2026, 1, 6), "Siti",
                "koreksi, \"stok opname\"");
        Path berkas = Path.of("uji-mutasi.csv");
        LaporanCsv.eksporMutasi(gudang.riwayat(), berkas);
        List<String> isi = Files.readAllLines(berkas, StandardCharsets.UTF_8);
        cek("laporan mutasi berisi header dan 2 baris", isi.size() == 3);
        cek("baris terakhir terpelolos dengan benar",
                isi.get(2).endsWith(",\"koreksi, \"\"stok opname\"\"\""));
    }
}

Output yang Dihasilkan:

text
PASS: barang terdaftar ditemukan lewat kode
PASS: kode ganda ditolak
PASS: masuk 50 keluar 20 menyisakan stok 30
PASS: keluar melebihi stok melempar StokTidakCukupException
PASS: stok tidak berubah setelah penolakan
PASS: tidak ada jurnal untuk transaksi yang ditolak
PASS: kode tak dikenal melempar BarangTidakDitemukanException
PASS: jumlah nol ditolak
PASS: riwayat tercatat urut dengan id 1, 2, 3
PASS: urutan riwayat mengikuti urutan pemanggilan
PASS: stok selalu sama dengan penjumlahan jurnal
PASS: pencarian huruf kecil menemukan 2 kertas
PASS: pencarian huruf besar memberi hasil identik
PASS: kata tanpa padanan menghasilkan daftar kosong
PASS: filter restok menemukan 2 barang
PASS: filter restok terurut naik berdasarkan kode
PASS: berkas memuat kembali barang yang sama
PASS: berkas memuat kembali seluruh jurnal
PASS: id mutasi berlanjut setelah dibuka ulang
PASS: nilai polos tidak dibungkus
PASS: nilai berkoma dibungkus kutip ganda
PASS: kutip asli digandakan di dalam pembungkus
PASS: laporan mutasi berisi header dan 2 baris
PASS: baris terakhir terpelolos dengan benar

Ringkasan: 24 lulus, 0 gagal

Kelompok pengujian dan apa yang dibuktikannya:

  1. ujiStokTidakCukup: inti proyek — permintaan 11 dengan stok 10 melempar StokTidakCukupException, dan dua pemeriksaan lanjutan membuktikan penolakan tidak meninggalkan jejak: stok tetap 10 dan jurnal tidak bertambah.
  2. ujiMutasiTercatatUrut: id riwayat 1, 2, 3 dan catatan ketiga ada di posisi terakhir — jurnal mengikuti urutan pemanggilan, bukan urutan lain.
  3. ujiJurnalSelarasStok: invarian Bagian 1 diuji ulang setelah tiga transaksi — penjumlahan tanda() * jumlah() sama persis dengan field stok.
  4. ujiPencarian dan ujiFilterRestok: "kertas" dan "KERTAS" menghasilkan daftar yang setara, kata tanpa padanan menghasilkan daftar kosong, dan filter restok mengembalikan dua barang terurut kode.
  5. ujiPersistensiBerkas dan ujiPelolosanCsv: bolak-balik berkas mengembalikan barang dan jurnal yang setara dengan id yang berlanjut, serta tiga kasus pelolosan CSV termasuk baris asli hasil ekspor dibaca kembali.

Padanan JUnit 5 #

Dua puluh empat cek di atas adalah apa yang ditulis JUnit sebagai metode @Test berisi assertThrows dan assertEquals. JUnit bukan bagian JDK — ia ditambahkan sebagai dependensi build — sehingga blok berikut rancangan tanpa klaim eksekusi; panduan resminya ada di daftar Referensi:

java
import java.math.BigDecimal;
import java.time.LocalDate;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

class GudangTest {

    private Gudang gudangSegil() {
        Gudang gudang = new Gudang(new GudangRepositoryMemori());
        gudang.daftar(new Barang("BRG-001", "Kertas HVS A4", "RIM",
                new BigDecimal("47500"), 0, 10));
        return gudang;
    }

    @Test
    void barangKeluarMelebihiStokDitolakTanpaJejak() {
        Gudang gudang = gudangSegil();
        gudang.barangMasuk("BRG-001", 10, LocalDate.of(2026, 1, 5), "Budi", "penerimaan awal");

        assertThrows(StokTidakCukupException.class, () -> gudang.barangKeluar(
                "BRG-001", 11, LocalDate.of(2026, 1, 6), "Siti", "permintaan"));
        assertEquals(10, gudang.cariBarang("BRG-001").stok());
        assertEquals(1, gudang.riwayat().size());   // jurnal tidak bertambah
    }

    @Test
    void mutasiTercatatUrutDanSelarasDenganStok() {
        Gudang gudang = gudangSegil();
        gudang.barangMasuk("BRG-001", 50, LocalDate.of(2026, 1, 5), "Budi", "awal");
        gudang.barangKeluar("BRG-001", 20, LocalDate.of(2026, 1, 8), "Siti", "permintaan");

        assertEquals(2, gudang.riwayat().size());
        assertEquals(1, gudang.riwayat().get(0).id());
        assertEquals(2, gudang.riwayat().get(1).id());
        assertEquals(30, gudang.cariBarang("BRG-001").stok());
    }

    @Test
    void pencarianTidakPekaHurufBesakecil() {
        Gudang gudang = gudangSegil();
        gudang.barangMasuk("BRG-001", 50, LocalDate.of(2026, 1, 5), "Budi", "awal");

        assertEquals(gudang.cariKataKunci("KERTAS"), gudang.cariKataKunci("kertas"));
        assertTrue(gudang.cariKataKunci("kertas").size() == 1);
    }
}

Terjemahannya hampir satu-ke-satu: satu method ujiX menjadi satu @Test, satu cek(...) menjadi satu assert*, dan blok try/catch yang menandai false menjadi assertThrows yang justru menggagalkan pengujian bila exception tidak muncul. Manfaatnya di skala besar: pelari uji otomatis, pelaporan per method, dan integrasi build.

Proyek aplikasi inventaris kini tuntas dari skema sampai pembuktian: model record tervalidasi, transaksi dengan aturan dan exception domain, penyimpanan yang bisa ditukar dari memori ke berkas tanpa menyentuh aturan bisnis, pencarian dan filter, laporan CSV yang tahan koma, dan dua puluh empat pemeriksaan yang menjaga semuanya. Langkah berikutnya tinggal mengikuti lapisan yang sudah disiapkan: mengganti GudangRepositoryBerkas dengan implementasi JDBC di atas database pilihan Anda — kontrak interface dan rancangan transaksi Bagian 2 tinggal menjadi kode hidup.

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.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.Locale;

// Bagian 4: ekspor laporan CSV untuk tabel barang dan jurnal mutasi.
public class DemoGudang {

    public static void main(String[] args) throws IOException {
        Gudang gudang = new Gudang(new GudangRepositoryMemori());
        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",
                "koreksi hasil \"stok opname\", selisih 8");
        gudang.barangKeluar("BRG-003", 17, LocalDate.of(2026, 1, 22), "Siti",
                "permintaan divisi keuangan");

        // 1. Laporan tabel barang.
        Path berkasBarang = Path.of("laporan-barang.csv");
        LaporanCsv.eksporBarang(gudang.semuaBarang(), berkasBarang);
        System.out.println("== laporan-barang.csv ==");
        System.out.print(Files.readString(berkasBarang, StandardCharsets.UTF_8));
        System.out.println();

        // 2. Laporan jurnal mutasi, termasuk catatan yang memuat koma dan kutip.
        Path berkasMutasi = Path.of("laporan-mutasi.csv");
        LaporanCsv.eksporMutasi(gudang.riwayat(), berkasMutasi);
        System.out.println("== laporan-mutasi.csv ==");
        System.out.print(Files.readString(berkasMutasi, StandardCharsets.UTF_8));
        System.out.println();

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

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

LaporanCsv.java

Unduh berkas
java
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

// Ekspor laporan CSV: baris pertama nama kolom, pemisah koma, nilai yang
// memuat karakter khusus dibungkus kutip ganda sesuai RFC 4180.
public final class LaporanCsv {

    private LaporanCsv() {
        // Kelas utilitas: seluruh anggota static, tidak perlu instance.
    }

    /** Laporan tabel barang: kode, nama, satuan, harga, stok, stok_minimum. */
    public static void eksporBarang(List<Barang> barang, Path berkas) {
        List<String> baris = new ArrayList<>();
        baris.add("kode,nama,satuan,harga,stok,stok_minimum");
        for (Barang b : barang) {
            baris.add(csv(b.kode()) + "," + csv(b.nama()) + "," + csv(b.satuan())
                    + "," + b.harga().toPlainString() + "," + b.stok() + ","
                    + b.stokMinimum());
        }
        tulis(berkas, baris);
    }

    /** Laporan jurnal mutasi: id, tanggal, kode_barang, jenis, jumlah, petugas, catatan. */
    public static void eksporMutasi(List<MutasiStok> mutasi, Path berkas) {
        List<String> baris = new ArrayList<>();
        baris.add("id,tanggal,kode_barang,jenis,jumlah,petugas,catatan");
        for (MutasiStok m : mutasi) {
            baris.add(m.id() + "," + m.tanggal() + "," + csv(m.kodeBarang()) + ","
                    + m.jenis() + "," + m.jumlah() + "," + csv(m.petugas()) + ","
                    + csv(m.catatan()));
        }
        tulis(berkas, baris);
    }

    /**
     * Meloloskan satu nilai CSV: bila nilai memuat koma, kutip ganda, atau
     * baris baru, seluruh nilai dibungkus kutip ganda dan setiap kutip asli
     * digandakan (" menjadi "").
     */
    static String csv(String nilai) {
        if (nilai.indexOf(',') >= 0 || nilai.indexOf('"') >= 0
                || nilai.indexOf('\n') >= 0) {
            return '"' + nilai.replace("\"", "\"\"") + '"';
        }
        return nilai;
    }

    private static void tulis(Path berkas, List<String> baris) {
        try {
            Files.write(berkas, baris, StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new GudangException("gagal menulis laporan: " + berkas, e);
        }
    }
}

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

UjiGudang.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.util.List;

// Harness pengujian berbasis main: PASS/FAIL tanpa pustaka eksternal.
// Exit code 1 bila ada kegagalan — sinyal bagi proses pemanggil.
public class UjiGudang {

    private static int lulus = 0;
    private static int gagal = 0;

    public static void main(String[] args) throws IOException {
        ujiPendaftaran();
        ujiMutasiMenaikkanDanMenurunkan();
        ujiStokTidakCukup();
        ujiBarangTidakDitemukan();
        ujiJumlahTidakPositif();
        ujiMutasiTercatatUrut();
        ujiJurnalSelarasStok();
        ujiPencarian();
        ujiFilterRestok();
        ujiPersistensiBerkas();
        ujiPelolosanCsv();
        System.out.println();
        System.out.println("Ringkasan: " + lulus + " lulus, " + gagal + " gagal");
        if (gagal > 0) {
            System.exit(1);
        }
    }

    /** Satu pernyataan uji: catat PASS bila kondisi benar, FAIL bila salah. */
    private static void cek(String nama, boolean kondisi) {
        if (kondisi) {
            lulus++;
            System.out.println("PASS: " + nama);
        } else {
            gagal++;
            System.out.println("FAIL: " + nama);
        }
    }

    /** Gudang segil berisi satu barang uji. */
    private static Gudang gudangSegil() {
        Gudang gudang = new Gudang(new GudangRepositoryMemori());
        gudang.daftar(new Barang("BRG-001", "Kertas HVS A4", "RIM",
                new BigDecimal("47500"), 0, 10));
        return gudang;
    }

    /** Gudang lima barang dengan mutasi campuran untuk uji pencarian dan filter. */
    private static Gudang gudangLimaBarang() {
        Gudang gudang = new Gudang(new GudangRepositoryMemori());
        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.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-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");
        return gudang;
    }

    private static void ujiPendaftaran() {
        Gudang gudang = gudangSegil();
        cek("barang terdaftar ditemukan lewat kode",
                gudang.cariBarang("BRG-001").nama().equals("Kertas HVS A4"));
        try {
            gudang.daftar(new Barang("BRG-001", "Kertas HVS A4", "RIM",
                    new BigDecimal("47500"), 0, 10));
            cek("kode ganda ditolak", false);
        } catch (GudangException e) {
            cek("kode ganda ditolak", e.getMessage().contains("sudah terdaftar"));
        }
    }

    private static void ujiMutasiMenaikkanDanMenurunkan() {
        Gudang gudang = gudangSegil();
        gudang.barangMasuk("BRG-001", 50, LocalDate.of(2026, 1, 5), "Budi", "penerimaan awal");
        gudang.barangKeluar("BRG-001", 20, LocalDate.of(2026, 1, 8), "Siti", "permintaan");
        cek("masuk 50 keluar 20 menyisakan stok 30",
                gudang.cariBarang("BRG-001").stok() == 30);
    }

    /** Aturan inti: stok tidak boleh minus, penolakan tidak meninggalkan jejak. */
    private static void ujiStokTidakCukup() {
        Gudang gudang = gudangSegil();
        gudang.barangMasuk("BRG-001", 10, LocalDate.of(2026, 1, 5), "Budi", "penerimaan awal");
        int stokSebelum = gudang.cariBarang("BRG-001").stok();
        int mutasiSebelum = gudang.riwayat().size();
        try {
            gudang.barangKeluar("BRG-001", 11, LocalDate.of(2026, 1, 6), "Siti", "permintaan");
            cek("keluar melebihi stok melempar StokTidakCukupException", false);
        } catch (StokTidakCukupException e) {
            cek("keluar melebihi stok melempar StokTidakCukupException",
                    e.getMessage().contains("tersisa 10"));
        }
        cek("stok tidak berubah setelah penolakan",
                gudang.cariBarang("BRG-001").stok() == stokSebelum);
        cek("tidak ada jurnal untuk transaksi yang ditolak",
                gudang.riwayat().size() == mutasiSebelum);
    }

    private static void ujiBarangTidakDitemukan() {
        Gudang gudang = gudangSegil();
        try {
            gudang.barangMasuk("BRG-009", 5, LocalDate.of(2026, 1, 5), "Budi", "uji");
            cek("kode tak dikenal melempar BarangTidakDitemukanException", false);
        } catch (BarangTidakDitemukanException e) {
            cek("kode tak dikenal melempar BarangTidakDitemukanException",
                    e.getMessage().contains("BRG-009"));
        }
    }

    private static void ujiJumlahTidakPositif() {
        Gudang gudang = gudangSegil();
        gudang.barangMasuk("BRG-001", 10, LocalDate.of(2026, 1, 5), "Budi", "penerimaan awal");
        try {
            gudang.barangMasuk("BRG-001", 0, LocalDate.of(2026, 1, 6), "Budi", "uji");
            cek("jumlah nol ditolak", false);
        } catch (GudangException e) {
            cek("jumlah nol ditolak", e.getMessage().contains("positif"));
        }
    }

    private static void ujiMutasiTercatatUrut() {
        Gudang gudang = gudangSegil();
        gudang.barangMasuk("BRG-001", 10, LocalDate.of(2026, 1, 5), "Budi", "pertama");
        gudang.barangMasuk("BRG-001", 5, LocalDate.of(2026, 1, 6), "Siti", "kedua");
        gudang.barangKeluar("BRG-001", 3, LocalDate.of(2026, 1, 7), "Dewi", "ketiga");
        List<MutasiStok> riwayat = gudang.riwayat();
        cek("riwayat tercatat urut dengan id 1, 2, 3",
                riwayat.get(0).id() == 1 && riwayat.get(1).id() == 2
                        && riwayat.get(2).id() == 3);
        cek("urutan riwayat mengikuti urutan pemanggilan",
                riwayat.get(2).catatan().equals("ketiga"));
    }

    /** Invarian Bagian 1: stok selalu sama dengan penjumlahan seluruh jurnal. */
    private static void ujiJurnalSelarasStok() {
        Gudang gudang = gudangSegil();
        gudang.barangMasuk("BRG-001", 50, LocalDate.of(2026, 1, 5), "Budi", "awal");
        gudang.barangKeluar("BRG-001", 20, LocalDate.of(2026, 1, 8), "Siti", "permintaan");
        gudang.barangMasuk("BRG-001", 7, LocalDate.of(2026, 1, 10), "Dewi", "koreksi");
        int dariJurnal = 0;
        for (MutasiStok m : gudang.riwayat()) {
            dariJurnal += m.jenis().tanda() * m.jumlah();
        }
        cek("stok selalu sama dengan penjumlahan jurnal",
                gudang.cariBarang("BRG-001").stok() == dariJurnal);
    }

    private static void ujiPencarian() {
        Gudang gudang = gudangLimaBarang();
        cek("pencarian huruf kecil menemukan 2 kertas",
                gudang.cariKataKunci("kertas").size() == 2);
        cek("pencarian huruf besar memberi hasil identik",
                gudang.cariKataKunci("KERTAS").equals(gudang.cariKataKunci("kertas")));
        cek("kata tanpa padanan menghasilkan daftar kosong",
                gudang.cariKataKunci("kursi").isEmpty());
    }

    private static void ujiFilterRestok() {
        Gudang gudang = gudangLimaBarang();
        List<Barang> menipis = gudang.barangPerluRestok();
        cek("filter restok menemukan 2 barang", menipis.size() == 2);
        cek("filter restok terurut naik berdasarkan kode",
                menipis.get(0).kode().equals("BRG-002")
                        && menipis.get(1).kode().equals("BRG-003"));
    }

    private static void ujiPersistensiBerkas() throws IOException {
        Path berkas = Path.of("uji-gudang.txt");
        Files.deleteIfExists(berkas);   // mulai bersih agar uji dapat diulang

        Gudang pertama = new Gudang(new GudangRepositoryBerkas(berkas));
        pertama.daftar(new Barang("BRG-001", "Kertas HVS A4", "RIM",
                new BigDecimal("47500"), 0, 10));
        pertama.barangMasuk("BRG-001", 50, LocalDate.of(2026, 1, 5), "Budi", "penerimaan awal");
        pertama.barangKeluar("BRG-001", 20, LocalDate.of(2026, 1, 8), "Siti", "permintaan");

        Gudang kedua = new Gudang(new GudangRepositoryBerkas(berkas));
        cek("berkas memuat kembali barang yang sama",
                kedua.cariBarang("BRG-001").equals(pertama.cariBarang("BRG-001")));
        cek("berkas memuat kembali seluruh jurnal",
                kedua.riwayat().equals(pertama.riwayat()));
        MutasiStok lanjutan = kedua.barangMasuk("BRG-001", 5,
                LocalDate.of(2026, 1, 25), "Dewi", "pembelian rutin");
        cek("id mutasi berlanjut setelah dibuka ulang", lanjutan.id() == 3);
    }

    private static void ujiPelolosanCsv() throws IOException {
        cek("nilai polos tidak dibungkus",
                LaporanCsv.csv("Kertas HVS A4").equals("Kertas HVS A4"));
        cek("nilai berkoma dibungkus kutip ganda",
                LaporanCsv.csv("a,b").equals("\"a,b\""));
        cek("kutip asli digandakan di dalam pembungkus",
                LaporanCsv.csv("kata \"x\"").equals("\"kata \"\"x\"\"\""));

        Gudang gudang = gudangSegil();
        gudang.barangMasuk("BRG-001", 10, LocalDate.of(2026, 1, 5), "Budi", "penerimaan awal");
        gudang.barangKeluar("BRG-001", 2, LocalDate.of(2026, 1, 6), "Siti",
                "koreksi, \"stok opname\"");
        Path berkas = Path.of("uji-mutasi.csv");
        LaporanCsv.eksporMutasi(gudang.riwayat(), berkas);
        List<String> isi = Files.readAllLines(berkas, StandardCharsets.UTF_8);
        cek("laporan mutasi berisi header dan 2 baris", isi.size() == 3);
        cek("baris terakhir terpelolos dengan benar",
                isi.get(2).endsWith(",\"koreksi, \"\"stok opname\"\"\""));
    }
}