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:
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 keuanganImplementasinya 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:
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 + "]";
}
}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:
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:
== 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 BudiMari kita baca keluarannya:
- Sesi pertama: lima barang terisi oleh lima penerimaan; tiga permintaan menurunkan BRG-001 ke 25, BRG-002 ke 2, dan BRG-003 ke 3.
setelah dibuka ulang : 5 barang, 8 mutasi: instance kedua membaca berkas yang sama tanpa satu baris inisialisasi ulang.mutasi baruber-id 9 membuktikan counter identitas dipulihkan dari baris terakhir jurnal.cari "kertas"dancari "KERTAS": keduanya menghasilkan dua barang yang sama — pencarian tidak peka huruf besar-kecil, diuji dari dua arah.barang perlu restok: Tinta Printer Hitam tepat menyentuh minimum 8 setelah ditaruh 6 botol, dan Kertas HVS F4 tersisa 3 dari minimum 8.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:
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:
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 #
- Oracle: Interface PreparedStatement (Java SE 25 API)
- Oracle: Class Locale (Java SE 25 API)
- Oracle: Interface Stream (Java SE 25 API)
- dev.java — Java I/O
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
Barang.java
Unduh berkasimport 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// Operasi menunjuk kode barang yang tidak terdaftar di penyimpanan.
public class BarangTidakDitemukanException extends GudangException {
public BarangTidakDitemukanException(String pesan) {
super(pesan);
}
}
DemoGudang.java
Unduh berkasimport 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 berkasimport 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// 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 berkasimport 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 berkasimport 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 berkasimport 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// 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 berkasimport 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// Permintaan barang keluar melebihi stok yang tersedia saat itu.
public class StokTidakCukupException extends GudangException {
public StokTidakCukupException(String pesan) {
super(pesan);
}
}