Tampilan
Kenapa penting
Laporan penjualan adalah salah satu tugas paling umum di backend: dashboard admin, rekap harian ke tim keuangan, atau endpoint "statistik toko". Polanya selalu mirip — ambil daftar transaksi, buang yang tidak relevan, kelompokkan, jumlahkan, urutkan. Proyek ini merangkai semua yang kamu pelajari di M04 (record, lambda, stream, collector, Optional, fungsi murni) menjadi satu class laporan yang utuh, bisa dijalankan, dan bisa dibuktikan kebenarannya. Kemampuan memeriksa angka laporan terhadap perhitungan manual sama pentingnya dengan menulis kodenya — angka yang salah di laporan keuangan jauh lebih berbahaya daripada error yang langsung terlihat.
Konsep inti
Kebutuhan
Diberikan daftar pesanan sebuah toko kopi online, buat class SalesReport yang menghasilkan:
| No | Laporan | Bentuk hasil | Aturan |
|---|---|---|---|
| 1 | Omzet per kategori | Map<String, Long> | urut nama kategori |
| 2 | N produk terlaris (unit) | List<ProductSales> | jumlah terbanyak dulu; seri → nama produk A–Z |
| 3 | Pelanggan dengan pesanan terbanyak | Optional<String> | seri → email A–Z; kosong bila tak ada pesanan |
| 4 | Rata-rata nilai pesanan per bulan | Map<YearMonth, Long> | urut kronologis; dibulatkan ke rupiah terdekat |
Aturan umum: hanya pesanan berstatus PAID yang dihitung. Nilai uang memakai long dalam rupiah, bukan double, agar tidak ada galat pembulatan.
Desain
OrderLine— satu baris pesanan: SKU, nama produk, kategori, jumlah, harga satuan; punyasubtotal().Order— ID, email pelanggan, tanggal, status, dan daftarOrderLine(disalin denganList.copyOf); punyatotal().ProductSales— hasil laporan 2: nama produk dan jumlah unit.SalesReport— menerima daftar pesanan, memfilterPAIDsekali di constructor, lalu setiap laporan adalah method yang murni terhadap data itu.
Semua laporan dibangun dari dua bentuk stream: stream Order (untuk laporan per pesanan) dan stream OrderLine hasil flatMap (untuk laporan per produk/kategori). Memilih level yang tepat adalah keputusan desain pertama di setiap laporan.
YearMonth sebagai kunci
Laporan bulanan butuh kunci "tahun + bulan". java.time.YearMonth tepat untuk itu: ia immutable, punya equals dan hashCode yang benar, serta Comparable sehingga terurut kronologis di TreeMap. (Paket java.time dibahas lengkap di M06; di sini cukup tiga method.)
java
import java.time.LocalDate;
import java.time.YearMonth;
public class YearMonthDemo {
public static void main(String[] args) {
YearMonth july = YearMonth.from(LocalDate.of(2026, 7, 28));
System.out.println(july);
System.out.println(july.equals(YearMonth.of(2026, 7)));
System.out.println(july.isBefore(YearMonth.of(2026, 8)));
System.out.println(july.plusMonths(6) + " " + july.lengthOfMonth());
}
}Output:
text
2026-07
true
true
2027-01 31Langkah 1 — model data
Semua potongan langkah 1–6 berada di dalam satu file SalesReport.java (class SalesReport). Import yang dibutuhkan:
java
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
import java.util.stream.Collectors;java
enum Status { PAID, CANCELLED }
record OrderLine(String sku, String product, String category, int qty, long unitPrice) {
long subtotal() {
return unitPrice * qty;
}
}
record Order(String id, String customer, LocalDate date, Status status, List<OrderLine> lines) {
Order {
lines = List.copyOf(lines);
}
long total() {
return lines.stream().mapToLong(OrderLine::subtotal).sum();
}
}
record ProductSales(String product, int qty) {}
private final List<Order> paidOrders;
SalesReport(List<Order> orders) {
this.paidOrders = orders.stream()
.filter(order -> order.status() == Status.PAID)
.toList();
}subtotal() dan total() adalah fungsi murni yang diturunkan dari data — tidak disimpan sebagai field sehingga tidak mungkin "tidak sinkron" dengan isi baris pesanan.
Langkah 2 — omzet per kategori
Kategori ada di level OrderLine, jadi pesanan "dibuka" dulu dengan flatMap. groupingBy versi tiga argumen menerima map factory TreeMap::new agar kategori terurut.
java
// 1. Omzet per kategori, urut nama kategori
Map<String, Long> revenueByCategory() {
return paidOrders.stream()
.flatMap(order -> order.lines().stream())
.collect(Collectors.groupingBy(
OrderLine::category,
TreeMap::new,
Collectors.summingLong(OrderLine::subtotal)));
}Langkah 3 — produk terlaris
Dua tahap yang sengaja dipisah ke variabel bernama: agregasi jumlah per produk, lalu pengurutan. Comparator-nya bertingkat — jumlah menurun, lalu nama naik — supaya produk dengan jumlah sama selalu muncul dalam urutan yang pasti.
java
// 2. N produk terlaris berdasarkan jumlah unit; seri → nama produk A–Z
List<ProductSales> topProducts(int limit) {
Map<String, Integer> qtyByProduct = paidOrders.stream()
.flatMap(order -> order.lines().stream())
.collect(Collectors.groupingBy(
OrderLine::product,
Collectors.summingInt(OrderLine::qty)));
return qtyByProduct.entrySet().stream()
.map(entry -> new ProductSales(entry.getKey(), entry.getValue()))
.sorted(Comparator.comparingInt(ProductSales::qty).reversed()
.thenComparing(ProductSales::product))
.limit(limit)
.toList();
}Mengubah entry menjadi record ProductSales sebelum mengurutkan membuat comparator mudah dibaca dan hasilnya punya nama field yang jelas bagi pemanggil.
Langkah 4 — pelanggan teraktif
Di sini levelnya Order: setiap pesanan dihitung satu. counting() menghasilkan Long. Untuk mengurutkan entry langsung, Map.Entry.comparingByValue() butuh type witness <String, Long> agar compiler tahu tipe entry sebelum .reversed() dipanggil.
java
// 3. Pelanggan dengan pesanan terbanyak; seri → email A–Z
Optional<String> topCustomer() {
Map<String, Long> ordersPerCustomer = paidOrders.stream()
.collect(Collectors.groupingBy(Order::customer, Collectors.counting()));
return ordersPerCustomer.entrySet().stream()
.sorted(Map.Entry.<String, Long>comparingByValue().reversed()
.thenComparing(Map.Entry.comparingByKey()))
.map(Map.Entry::getKey)
.findFirst();
}Langkah 5 — rata-rata nilai pesanan per bulan
averagingLong menghasilkan Double. Karena laporan memakai rupiah bulat, hasilnya diteruskan ke Math::round lewat collectingAndThen — collector yang menjalankan satu fungsi tambahan setelah collector di dalamnya selesai.
java
// 4. Rata-rata nilai pesanan per bulan (dibulatkan ke rupiah terdekat)
Map<YearMonth, Long> averageOrderValueByMonth() {
return paidOrders.stream()
.collect(Collectors.groupingBy(
order -> YearMonth.from(order.date()),
TreeMap::new,
Collectors.collectingAndThen(
Collectors.averagingLong(Order::total),
Math::round)));
}Langkah 6 — data contoh, pemeriksaan, dan main
Data contoh sengaja memuat kasus penting: satu pesanan CANCELLED, dua produk dengan jumlah terjual sama, dan pesanan di tiga bulan berbeda. Helper check membandingkan hasil dengan angka yang dihitung manual — versi sederhana dari unit test yang akan kamu tulis dengan JUnit di M05.
java
static List<Order> sampleOrders() {
OrderLine kopi = new OrderLine("KOPI-250", "Kopi Gayo 250 g", "Minuman", 1, 85_000);
OrderLine teh = new OrderLine("TEH-100", "Teh Melati 100 g", "Minuman", 1, 40_000);
OrderLine gula = new OrderLine("GULA-500", "Gula Aren 500 g", "Bahan", 1, 30_000);
OrderLine v60 = new OrderLine("V60-01", "Dripper V60", "Alat Seduh", 1, 150_000);
return List.of(
new Order("O-101", "sari@mail.com", LocalDate.of(2026, 7, 3), Status.PAID,
List.of(withQty(kopi, 2), withQty(gula, 1))),
new Order("O-102", "budi@mail.com", LocalDate.of(2026, 7, 15), Status.PAID,
List.of(withQty(v60, 1), withQty(kopi, 1))),
new Order("O-103", "adi@mail.com", LocalDate.of(2026, 7, 28), Status.CANCELLED,
List.of(withQty(v60, 3))),
new Order("O-104", "sari@mail.com", LocalDate.of(2026, 8, 2), Status.PAID,
List.of(withQty(teh, 3))),
new Order("O-105", "adi@mail.com", LocalDate.of(2026, 8, 19), Status.PAID,
List.of(withQty(kopi, 1), withQty(teh, 1), withQty(gula, 2))),
new Order("O-106", "budi@mail.com", LocalDate.of(2026, 9, 5), Status.PAID,
List.of(withQty(kopi, 3))),
new Order("O-107", "sari@mail.com", LocalDate.of(2026, 9, 21), Status.PAID,
List.of(withQty(gula, 4), withQty(v60, 1))));
}
static OrderLine withQty(OrderLine line, int qty) {
return new OrderLine(line.sku(), line.product(), line.category(), qty, line.unitPrice());
}
static void check(String name, Object actual, Object expected) {
String result = actual.equals(expected) ? "OK " : "GAGAL";
System.out.println(result + " " + name + " = " + actual);
}
public static void main(String[] args) {
SalesReport report = new SalesReport(sampleOrders());
System.out.println("Omzet per kategori : " + report.revenueByCategory());
System.out.println("Top 3 produk : " + report.topProducts(3));
System.out.println("Pelanggan teraktif : " + report.topCustomer().orElse("-"));
System.out.println("Rata-rata per bulan: " + report.averageOrderValueByMonth());
System.out.println();
check("omzet Minuman", report.revenueByCategory().get("Minuman"), 755_000L);
check("pesanan batal diabaikan", report.revenueByCategory().get("Alat Seduh"), 300_000L);
check("produk terlaris (seri → A–Z)", report.topProducts(1).getFirst().product(), "Gula Aren 500 g");
check("pelanggan teraktif", report.topCustomer(), Optional.of("sari@mail.com"));
check("rata-rata Juli", report.averageOrderValueByMonth().get(YearMonth.of(2026, 7)), 217_500L);
check("tanpa pesanan", new SalesReport(List.of()).topCustomer(), Optional.empty());
}Jalankan dengan java SalesReport.java. Output:
text
Omzet per kategori : {Alat Seduh=300000, Bahan=210000, Minuman=755000}
Top 3 produk : [ProductSales[product=Gula Aren 500 g, qty=7], ProductSales[product=Kopi Gayo 250 g, qty=7], ProductSales[product=Teh Melati 100 g, qty=4]]
Pelanggan teraktif : sari@mail.com
Rata-rata per bulan: {2026-07=217500, 2026-08=152500, 2026-09=262500}
OK omzet Minuman = 755000
OK pesanan batal diabaikan = 300000
OK produk terlaris (seri → A–Z) = Gula Aren 500 g
OK pelanggan teraktif = Optional[sari@mail.com]
OK rata-rata Juli = 217500
OK tanpa pesanan = Optional.emptyCara memeriksa hasil
Jangan percaya angka laporan sebelum kamu mencocokkannya dengan hitungan tangan, minimal untuk satu baris setiap laporan. Untuk data contoh di atas (pesanan O-103 yang batal diabaikan):
| Pemeriksaan | Hitungan manual | Hasil |
|---|---|---|
| Unit Kopi | 2 (O-101) + 1 (O-102) + 1 (O-105) + 3 (O-106) | 7 |
| Unit Gula | 1 (O-101) + 2 (O-105) + 4 (O-107) | 7 |
| Omzet Minuman | Kopi 7 × 85.000 + Teh 4 × 40.000 = 595.000 + 160.000 | 755.000 |
| Omzet Alat Seduh | Dripper 2 × 150.000 (O-102, O-107; O-103 batal) | 300.000 |
| Pesanan Sari | O-101, O-104, O-107 | 3 |
| Rata-rata Juli | (O-101 200.000 + O-102 235.000) ÷ 2 | 217.500 |
Lalu ubah data dengan sengaja untuk menguji aturan: ganti status O-103 menjadi PAID (omzet Alat Seduh harus naik menjadi 750.000 dan check kedua menjadi GAGAL), atau tambah satu unit Kopi (Kopi harus pindah ke posisi pertama). Bila hasil berubah sesuai dugaanmu, kamu benar-benar memahami pipeline-nya.
Contoh di dunia backend
Di aplikasi nyata, daftar Order datang dari database dan hasil laporan dikirim sebagai JSON. Pola yang sama tetap berlaku: lapisan luar mengambil data (efek samping), SalesReport menghitung (murni), lapisan luar mengirim hasil.
java
// Potongan: gambaran service setelah level Spring Boot, belum bisa dijalankan sendiri
public record SalesSummary(Map<String, Long> revenueByCategory,
List<SalesReport.ProductSales> topProducts,
String topCustomer) {}
public SalesSummary summary(YearMonth from, YearMonth to) {
List<SalesReport.Order> orders = orderRepository.findByMonthRange(from, to); // I/O di tepi
SalesReport report = new SalesReport(orders); // logika murni
return new SalesSummary(report.revenueByCategory(),
report.topProducts(5),
report.topCustomer().orElse(null));
}Karena SalesReport tidak bergantung pada database, ia bisa dites dengan data di memori seperti di atas. Untuk data yang sangat besar (jutaan baris), agregasi biasanya dipindahkan ke SQL GROUP BY agar tidak memuat semua baris ke memori — topik M06.
Latihan kode proyek ini ada di exercises/m04-java-modern/src/main/java/id/jalurjava/m04/SalesReport.java: model datanya sudah lengkap, tugasmu mengisi constructor dan setiap laporan (plus dua tantangan: omzet per bulan dan produk terlaris per kategori). Test-nya memakai data contoh yang sama dengan bab ini. Dari folder exercises/:
bash
./mvnw -pl m04-java-modern test -Dtest=SalesReportTestPertanyaan interview
1. Kapan memakai flatMap dalam laporan seperti ini?
Saat data yang ingin diagregasi berada satu level lebih dalam daripada elemen stream. Omzet per kategori dan produk terlaris dihitung dari OrderLine, sedangkan stream awalnya berisi Order; flatMap(order -> order.lines().stream()) meratakan semua baris menjadi satu stream. Laporan per pesanan (jumlah pesanan per pelanggan, rata-rata nilai pesanan) tetap di level Order.
2. Kenapa urutan hasil laporan harus ditentukan secara eksplisit?
groupingBy default memakai HashMap yang tidak menjamin urutan, dan elemen yang seri bisa muncul dalam urutan berbeda. Laporan yang urutannya berubah-ubah membingungkan pengguna dan membuat test rapuh. Pakai TreeMap::new atau comparator bertingkat dengan tie-breaker yang jelas.
3. Kenapa nilai uang disimpan sebagai long rupiah, bukan double?
double tidak bisa merepresentasikan banyak pecahan desimal secara tepat sehingga penjumlahan berulang menghasilkan galat kecil. Menyimpan satuan terkecil sebagai long (atau memakai BigDecimal bila ada sen dan aturan pembulatan khusus) menjaga hasil tetap pasti. Pembulatan, seperti pada rata-rata, dilakukan sekali secara eksplisit.
Jebakan umum
- Lupa memfilter status sebelum agregasi — pesanan batal ikut terhitung dan angka terlihat "masuk akal".
toMapuntuk menghitung →IllegalStateException: Duplicate key sari@mail.com (attempted merging values O-101 and O-104). PakaigroupingBy+counting().- Tanpa tie-breaker pada pengurutan — produk dengan jumlah sama bertukar posisi tanpa alasan jelas.
- Mengelompokkan per
getMonthValue()— Juli 2026 dan Juli 2027 tercampur. PakaiYearMonth. - Memakai
doubleuntuk uang —0.1 + 0.2menghasilkan0.30000000000000004; galat kecil menumpuk di total. - Menganggap laporan benar karena tidak error. Cocokkan minimal satu angka per laporan dengan hitungan manual.
Ringkasan
- Laporan = filter → (opsional
flatMap) →groupingBy+ collector hilir → urutkan → batasi. - Pilih level stream yang tepat:
Orderuntuk metrik per pesanan,OrderLineuntuk metrik per produk/kategori. TreeMap::newdan comparator bertingkat dengan tie-breaker membuat urutan hasil pasti.YearMonthadalah kunci yang tepat untuk laporan bulanan;collectingAndThenmerapikan hasil collector.- Logika laporan murni dan terpisah dari I/O sehingga bisa diverifikasi dengan data di memori dan hitungan manual.