Java — Từ Zero đến Senior/Mini-challenge: Sales report với stream pipeline
~30 phútStream API & LambdaMiễn phí

Mini-challenge: Sales report với stream pipeline

Bài thực hành khép lại Module 9 — phân tích 10k bản ghi sales bằng stream chain: lọc, group, aggregate, top-N, output báo cáo.

Mini-challenge khép lại Module 9. Bạn sẽ viết module phân tích List<Sale> (~10k record) và xuất báo cáo thuần stream API — không imperative loop.

Task điển hình của backend/data engineer: đọc log/database → aggregate → report. Với stream, toàn bộ pipeline declarative, đọc như câu tiếng Anh, dễ maintain hơn imperative.

🎯 Đề bài

Cho class Sale:

record Sale(String region, String product, String customer, double amount, LocalDate date) { }

Generate 10k record (helper có sẵn trong starter). Viết các method sau — mỗi method là 1 stream pipeline (không for/while):

1. double totalRevenue(List<Sale> sales)

Tổng amount.

2. Map<String, Double> revenueByRegion(List<Sale> sales)

Tổng doanh thu theo region.

3. Map<String, Long> orderCountByProduct(List<Sale> sales)

Số đơn theo product.

4. Optional<Sale> topSale(List<Sale> sales)

Đơn có amount cao nhất. Trả Optional vì list có thể rỗng.

5. List<String> topNProducts(List<Sale> sales, int n)

N product có doanh thu cao nhất, sorted desc.

6. Map<String, Map<String, Double>> revenueByRegionThenProduct(List<Sale> sales)

Group 2 tầng: region → product → tổng amount.

7. double averageOrderInRegion(List<Sale> sales, String region)

Trung bình amount các đơn thuộc region. Nếu không có đơn nào → trả 0.

8. List<Sale> salesInLastNDays(List<Sale> sales, int days, LocalDate today)

Đơn trong N ngày gần nhất (tính từ today).

Constraint: Tất cả implementation chỉ dùng stream pipeline — không for/while loop.

📦 Concept dùng trong bài

ConceptBàiDùng ở đây
Lambda, method reference9.1Mọi pipeline
Stream pipeline, terminal op9.2Kích hoạt pipeline
filter/map/reduce9.3Core operation
Collectors.groupingBy9.3Task 2, 3, 6
Collectors.summingDouble9.3Task 2, 6
Comparator, max/sorted9.3, 9.4Task 4, 5
flatMap, takeWhile9.4(không dùng trực tiếp nhưng tham khảo)
Optional9.5Task 4

▶️ Starter code

import java.time.LocalDate;
import java.util.*;
import java.util.stream.*;

public class SalesReport {

    public record Sale(String region, String product, String customer, double amount, LocalDate date) { }

    // Helper: generate fake data
    public static List<Sale> generate(int count) {
        String[] regions = {"VN", "US", "JP", "DE", "BR"};
        String[] products = {"Pen", "Book", "Laptop", "Phone", "Desk", "Chair"};
        Random rnd = new Random(42);
        LocalDate base = LocalDate.of(2025, 1, 1);

        return IntStream.range(0, count)
            .mapToObj(i -> new Sale(
                regions[rnd.nextInt(regions.length)],
                products[rnd.nextInt(products.length)],
                "user" + rnd.nextInt(1000),
                Math.round(rnd.nextDouble() * 1000 * 100.0) / 100.0,
                base.plusDays(rnd.nextInt(365))))
            .toList();
    }

    // TODO: implement 8 methods below

    public static double totalRevenue(List<Sale> sales) {
        return 0;
    }

    public static Map<String, Double> revenueByRegion(List<Sale> sales) {
        return Map.of();
    }

    public static Map<String, Long> orderCountByProduct(List<Sale> sales) {
        return Map.of();
    }

    public static Optional<Sale> topSale(List<Sale> sales) {
        return Optional.empty();
    }

    public static List<String> topNProducts(List<Sale> sales, int n) {
        return List.of();
    }

    public static Map<String, Map<String, Double>> revenueByRegionThenProduct(List<Sale> sales) {
        return Map.of();
    }

    public static double averageOrderInRegion(List<Sale> sales, String region) {
        return 0;
    }

    public static List<Sale> salesInLastNDays(List<Sale> sales, int days, LocalDate today) {
        return List.of();
    }

    public static void main(String[] args) {
        List<Sale> sales = generate(10_000);

        System.out.printf("Total revenue: %.2f%n", totalRevenue(sales));
        System.out.println("Revenue by region: " + revenueByRegion(sales));
        System.out.println("Order count by product: " + orderCountByProduct(sales));
        topSale(sales).ifPresent(s -> System.out.println("Top sale: " + s));
        System.out.println("Top 3 products: " + topNProducts(sales, 3));
        System.out.println("Revenue by region -> product (VN only): "
            + revenueByRegionThenProduct(sales).get("VN"));
        System.out.printf("VN average order: %.2f%n", averageOrderInRegion(sales, "VN"));
        System.out.println("Last 7 days count: "
            + salesInLastNDays(sales, 7, LocalDate.of(2025, 12, 31)).size());
    }
}
javac SalesReport.java
java SalesReport

Dành 25–30 phút tự làm. Tự test với output kỳ vọng.

💡 Gợi ý

💡 Gợi ý — đọc khi bị kẹt

Task 1 — totalRevenue: mapToDouble + sum. Tránh boxing.

return sales.stream().mapToDouble(Sale::amount).sum();

Task 2 — revenueByRegion: groupingBy với downstream summingDouble.

return sales.stream()
    .collect(Collectors.groupingBy(
        Sale::region,
        Collectors.summingDouble(Sale::amount)));

Task 3 — orderCountByProduct: groupingBy với downstream counting.

return sales.stream()
    .collect(Collectors.groupingBy(Sale::product, Collectors.counting()));

Task 4 — topSale: max với Comparator.comparingDouble — trả Optional tự nhiên.

return sales.stream().max(Comparator.comparingDouble(Sale::amount));

Task 5 — topNProducts: 2 step: tính revenue per product (Map), rồi sort entries + limit.

return sales.stream()
    .collect(Collectors.groupingBy(Sale::product, Collectors.summingDouble(Sale::amount)))
    .entrySet().stream()
    .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
    .limit(n)
    .map(Map.Entry::getKey)
    .toList();

Task 6 — revenueByRegionThenProduct: groupingBy lồng.

return sales.stream()
    .collect(Collectors.groupingBy(
        Sale::region,
        Collectors.groupingBy(
            Sale::product,
            Collectors.summingDouble(Sale::amount))));

Task 7 — averageOrderInRegion: filter + mapToDouble.average, trả OptionalDouble. orElse(0) khi empty.

return sales.stream()
    .filter(s -> s.region().equals(region))
    .mapToDouble(Sale::amount)
    .average()
    .orElse(0);

Task 8 — salesInLastNDays: filter so sánh date. isAfter/isBefore của LocalDate.

LocalDate cutoff = today.minusDays(days);
return sales.stream()
    .filter(s -> !s.date().isBefore(cutoff) && !s.date().isAfter(today))
    .toList();

✅ Lời giải

✅ Lời giải — xem sau khi đã thử
import java.time.LocalDate;
import java.util.*;
import java.util.stream.*;

public class SalesReport {

    public record Sale(String region, String product, String customer, double amount, LocalDate date) { }

    public static List<Sale> generate(int count) {
        String[] regions = {"VN", "US", "JP", "DE", "BR"};
        String[] products = {"Pen", "Book", "Laptop", "Phone", "Desk", "Chair"};
        Random rnd = new Random(42);
        LocalDate base = LocalDate.of(2025, 1, 1);

        return IntStream.range(0, count)
            .mapToObj(i -> new Sale(
                regions[rnd.nextInt(regions.length)],
                products[rnd.nextInt(products.length)],
                "user" + rnd.nextInt(1000),
                Math.round(rnd.nextDouble() * 1000 * 100.0) / 100.0,
                base.plusDays(rnd.nextInt(365))))
            .toList();
    }

    public static double totalRevenue(List<Sale> sales) {
        return sales.stream().mapToDouble(Sale::amount).sum();
    }

    public static Map<String, Double> revenueByRegion(List<Sale> sales) {
        return sales.stream()
            .collect(Collectors.groupingBy(
                Sale::region,
                Collectors.summingDouble(Sale::amount)));
    }

    public static Map<String, Long> orderCountByProduct(List<Sale> sales) {
        return sales.stream()
            .collect(Collectors.groupingBy(Sale::product, Collectors.counting()));
    }

    public static Optional<Sale> topSale(List<Sale> sales) {
        return sales.stream().max(Comparator.comparingDouble(Sale::amount));
    }

    public static List<String> topNProducts(List<Sale> sales, int n) {
        return sales.stream()
            .collect(Collectors.groupingBy(Sale::product, Collectors.summingDouble(Sale::amount)))
            .entrySet().stream()
            .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
            .limit(n)
            .map(Map.Entry::getKey)
            .toList();
    }

    public static Map<String, Map<String, Double>> revenueByRegionThenProduct(List<Sale> sales) {
        return sales.stream()
            .collect(Collectors.groupingBy(
                Sale::region,
                Collectors.groupingBy(
                    Sale::product,
                    Collectors.summingDouble(Sale::amount))));
    }

    public static double averageOrderInRegion(List<Sale> sales, String region) {
        return sales.stream()
            .filter(s -> s.region().equals(region))
            .mapToDouble(Sale::amount)
            .average()
            .orElse(0);
    }

    public static List<Sale> salesInLastNDays(List<Sale> sales, int days, LocalDate today) {
        LocalDate cutoff = today.minusDays(days);
        return sales.stream()
            .filter(s -> !s.date().isBefore(cutoff) && !s.date().isAfter(today))
            .toList();
    }

    public static void main(String[] args) {
        List<Sale> sales = generate(10_000);

        System.out.printf("Total revenue: %.2f%n", totalRevenue(sales));
        System.out.println("Revenue by region: " + revenueByRegion(sales));
        System.out.println("Order count by product: " + orderCountByProduct(sales));
        topSale(sales).ifPresent(s -> System.out.println("Top sale: " + s));
        System.out.println("Top 3 products: " + topNProducts(sales, 3));
        System.out.println("Revenue by region -> product (VN only): "
            + revenueByRegionThenProduct(sales).get("VN"));
        System.out.printf("VN average order: %.2f%n", averageOrderInRegion(sales, "VN"));
        System.out.println("Last 7 days count: "
            + salesInLastNDays(sales, 7, LocalDate.of(2025, 12, 31)).size());
    }
}

Điểm chính:

  • mapToDouble trước sum/average: tránh boxing Double → nhanh hơn với 10k+ record. DoubleStream.average() trả OptionalDouble vì stream rỗng chia cho 0.
  • Collectors.groupingBy + downstream: pattern chuẩn cho aggregate per group. Downstream có thể lồng để group 2-3 tầng.
  • max trả Optional tự nhiên: không phải null-guard thủ công.
  • Top-N pattern: map → entries → sort → limit → extract key. Đây là pattern tái dùng được với bất kỳ tiêu chí sort nào.
  • Date filter: isBefore/isAfter không inclusive — dùng phủ định !isBefore để include boundary.
  • Tất cả method declarative: đọc là hiểu, không cần mental-execute loop.

🎓 Mở rộng

Mức 1 — Handle empty list gracefully:

public static double totalRevenue(List<Sale> sales) {
    return sales == null ? 0 : sales.stream().mapToDouble(Sale::amount).sum();
}

Hoặc dùng Objects.requireNonNullElse(sales, List.of()). Defensive với API input.

Mức 2 — Parallel stream benchmark:

long t1 = System.nanoTime();
double r1 = sales.stream().mapToDouble(Sale::amount).sum();
long t2 = System.nanoTime();
double r2 = sales.parallelStream().mapToDouble(Sale::amount).sum();
long t3 = System.nanoTime();

System.out.printf("Sequential: %d us%n", (t2 - t1) / 1000);
System.out.printf("Parallel:   %d us%n", (t3 - t2) / 1000);

Với 10k element, sequential thường nhanh hơn (overhead fork/join > work). Tăng lên 10M → parallel thắng. Học bằng đo thật.

Mức 3 — Custom Collector:

Viết collector tính tuple (count, sum, min, max, avg) trong 1 pass:

record Stats(long count, double sum, double min, double max) {
    double average() { return count == 0 ? 0 : sum / count; }
}

Collector<Sale, ?, Stats> statsCollector = Collectors.teeing(
    Collectors.summarizingDouble(Sale::amount),
    Collectors.counting(),
    (summary, count) -> new Stats(count, summary.getSum(), summary.getMin(), summary.getMax())
);

Stats stats = sales.stream().collect(statsCollector);

Hoặc dùng Collectors.summarizingDouble built-in trả DoubleSummaryStatistics — 1 method đủ.

Mức 4 — Output formatted table:

Map<String, Map<String, Double>> grid = revenueByRegionThenProduct(sales);
grid.forEach((region, byProduct) -> {
    System.out.println("== " + region + " ==");
    byProduct.entrySet().stream()
        .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
        .forEach(e -> System.out.printf("  %-10s %,.2f%n", e.getKey(), e.getValue()));
});

Dùng forEach + nested stream — format sạch cho CLI report.

✨ Điều bạn vừa làm được

Hoàn thành mini-challenge này, bạn đã:

  • Viết 8 stream pipeline declarative, không for/while — code đọc như câu tiếng Anh.
  • Dùng groupingBy 2 tầng cho aggregate grid (region × product).
  • Tận dụng primitive stream (DoubleStream) để tránh boxing overhead.
  • Xử lý empty case không null check nhờ Optional/OptionalDouble.
  • Pattern top-N: map → entries → sort → limit → extract — tái dùng được với mọi tiêu chí.
  • Thực hành Comparator.comparingDouble, Map.Entry.comparingByValue().reversed() — API idiom Java modern.
  • Hiểu vì sao 80% code enterprise Java hiện nay viết theo stream — maintainability + correctness.

Chúc mừng — bạn đã hoàn thành Module 9! Bạn giờ đã có toolkit functional Java: lambda, stream, collector, Optional. Module 10 sẽ bước sang Concurrency — thread, synchronized, ExecutorService, và virtual threads (Java 21) — cốt lõi để viết backend high-throughput.

Bài này có giúp bạn hiểu bản chất không?