Java OO & Functional/Mini-challenge: Sales report với stream pipeline
33/33
Bài 33 / 33~30 phútStream API & LambdaMiễn phí lượt xem

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?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên