081+ Kiếp Nạn DAX Challenges

PBI Challenge #008 | Rolling N-Month Trend with Smart Tooltip

Tham gia khóa học Power BI Thực Chiến để giải 100+ Challenges từ căn bản đến nâng cao – Learning by Doing Challenges. Inbox mình Tại Đây.

Challenge

Xây dựng biểu đồ Rolling N-Month Trend với các tính năng:

  • Dynamic Period Selection – User chọn được khoảng thời gian: 3, 6, 9, hoặc 12 tháng
  • Year-over-Year Comparison – So sánh với cùng kỳ năm trước
  • Smart Tooltip – Hiển thị insight chi tiết khi hover vào biểu đồ:
    • Doanh thu hiện tại
    • So sánh với cùng kỳ năm trước
    • Phần trăm tăng/giảm (có màu và mũi tên)
    • Top 5 sản phẩm bán chạy nhất trong giai đoạn

Yêu Cầu 1: User có thể lựa chọn xem doanh thu tích lũy của 3, 6, 9, hoặc 12 tháng gần nhất.

  • Tạo một Slicer với 4 lựa chọn: “3 Tháng”, “6 Tháng”, “9 Tháng”, “12 Tháng”
  • Khi user chọn → Tất cả biểu đồ và KPI tự động cập nhật theo period đã chọn
  • Tiêu đề biểu đồ cũng phải tự động đổi

Yêu Cầu 2: Tại mỗi điểm trên biểu đồ, hiển thị tổng doanh thu của N tháng liền kề trước đó.

  • “Rolling” nghĩa là “trượt” – mỗi điểm đều cộng dồn N tháng gần nhất
  • VD: Rolling 6 Months Sales = Tổng doanh thu từ 04/2025 → 09/2025

Yêu Cầu 3: Tại mỗi điểm, hiển thị thêm đường so sánh với cùng kỳ năm trước để đánh giá tăng trưởng.

  • Rolling N Months Sales LY = Lùi 1 năm so với giai đoạn hiện tại
  • Hiển thị bằng đường nét đứt trên biểu đồ
  • VD:
    • Rolling 9M Sales (TY) = 09/2024 → 05/2025
    • Rolling 9M Sales (LY) 09/2023 → 05/2024

Yêu Cầu 4: Khi hover vào một điểm trên biểu đồ, hiển thị tooltip với đầy đủ thông tin phân tích.

Thành phầnMô tảVí dụ
Tiêu đềPeriod + Tháng đang xem“Rolling 9 Months Sales: 05/2025”
Doanh thu chính846.54M
vs YOYSo sánh cùng kỳ914.38M
Growth %Có mũi tên + màu sắc▼ 7.4% (đỏ)
VarianceChênh lệch tuyệt đối▼ 67.8M (đỏ)
Top 5 ProductsSản phẩm bán chạy nhấtDanh sách 5 sản phẩm

Yêu Cầu 5: Trong tooltip, hiển thị 5 sản phẩm có doanh thu cao nhất trong giai đoạn Rolling N tháng.

  • Cho phép user nhanh chóng biết sản phẩm nào đang “hot” và sản phẩm nào đang sụt giảm trong giai đoạn đang xem.
  • Format hiển thị: [Rank]. [Tên SP]: [Doanh thu] ([YoY%])

Yêu Cầu 6: Vẽ biểu đồ xu hướng với 2 đường để dễ so sánh TY vs LY.

  • Đường nét liền: Rolling N Months Sales (Năm nay)
  • Đường nét đứt: Rolling N Months Sales LY (Cùng kỳ năm trước)
  • Đường phải dừng lại tại tháng có dữ liệu cuối cùng (không vẽ ra tương lai)

Yêu Cầu 7: Tiêu đề biểu đồ tự động thay đổi theo context.

  • Format: “Rolling [N] Months Sales: [MM/YYYY]”
  • VD: Chọn “3 Tháng” → “Rolling 3 Months Sales: 12/2025”

DAX Code Step by Step

Bước 01: User-Defined Functions (UDF)

Định nghĩa UDFs trong DAX Query View:

DEFINE

-- Format số với K/M/B suffix
FUNCTION FormatKMB = (num : NUMERIC) =>
    -- Nhận vào 1 giá trị số (Scalar), trả về 1 chuỗi text (Scalar).
    SWITCH(
        TRUE(),
        -- Input 1,500,000,000. ABS >= 1E9 (1 Tỷ) -> True.
        -- 1.5 tỷ / 1 tỷ = 1.5. Format chuỗi -> "1.5B"
        ABS(num) >= 1E9, FORMAT(num / 1E9, "#,##0.0") & "B",
        
        -- Input -2,500,000. ABS >= 1E6 (1 Triệu) -> True.
        -- -2.5 triệu / 1 triệu = -2.5. Format chuỗi -> "-2.5M"
        ABS(num) >= 1E6, FORMAT(num / 1E6, "#,##0.0") & "M",
        
        -- Input 15,000. ABS >= 1E3 (1 Nghìn) -> True. Output "15.0K"
        ABS(num) >= 1E3, FORMAT(num / 1E3, "#,##0.0") & "K",
        
        -- Input 500. Các điều kiện trên False. Giữ nguyên số. Output "500"
        FORMAT(num, "#,##0")
    )

-- Lấy mũi tên chỉ hướng
FUNCTION GetArrow = (num : NUMERIC) =>
    IF(num >= 0, "▲ ", "▼ ")

-- Format % (Chỉ lấy giá trị tuyệt đối)
FUNCTION FormatPercent = (num : NUMERIC) =>
    -- Hàm FORMAT chuyển đổi số sang dạng text theo mask "0.0%".
    -- Dùng ABS(num) vì dấu âm/dương sẽ được thể hiện bằng Mũi tên ở hàm khác.
    -- Input -0.153 -> ABS(0.153) -> Output "15.3%"
    FORMAT(ABS(num), "0.0%")

-- Get Color từ Text (Phân tích chuỗi kết quả để gán màu)
FUNCTION GetColorFromText = (txt : STRING) =>
    -- Input là STRING (Kết quả của hàm FormatWithArrow), không phải số.
    -- CONTAINSSTRING thực hiện tìm kiếm text (Case-insensitive).
    SWITCH(
        TRUE(),
        -- Input "▲ 15.3%". Tìm thấy "▲" -> True -> Trả về Hex Xanh
        CONTAINSSTRING(txt, "▲"), "#0d6a3f",  -- Xanh (tăng)
        
        -- Input "▼ 2.1M". Tìm thấy "▼" -> True -> Trả về Hex Đỏ
        CONTAINSSTRING(txt, "▼"), "#a1343c",  -- Đỏ (giảm)
        
        -- [Default]: Không tìm thấy ký tự đặc biệt -> Trả về màu Xám
        "#909090"                              -- Xám (còn lại)
    )

-- Format số lượng lớn kèm mũi tên
FUNCTION FormatWithArrow = (num : NUMERIC) =>
    -- Function Chaining (Gọi hàm trong hàm).
    -- Toán tử "&" dùng để nối chuỗi (String Concatenation).
    -- Input 35,600,000
    --    Step 1: GetArrow(35.6M) -> "▲ "
    --    Step 2: FormatKMB(35.6M) -> "35.6M" (ABS đã xử lý trong hàm con)
    --    Result: "▲ 35.6M"
    GetArrow(num) & FormatKMB(ABS(num))

-- Format phần trăm kèm mũi tên
FUNCTION FormatPercentWithArrow = (num : NUMERIC) =>
    -- Tương tự trên, tái sử dụng logic đã định nghĩa để đảm bảo nhất quán.
    -- Input -0.052
    --    Step 1: GetArrow(-0.052) -> "▼ "
    --    Step 2: FormatPercent(-0.052) -> "5.2%"
    --    Result: "▼ 5.2%"
    GetArrow(num) & FormatPercent(num)
Bước 02: Tạo Bảng Tham Số
Rolling Period Table = 
DATATABLE(
    "Period", INTEGER,
    "Label", STRING,
    {
        {3, "3 Tháng"},
        {6, "6 Tháng"},
        {9, "9 Tháng"},
        {12, "12 Tháng"}
    }
)
Bước 03
Selected Period = SELECTEDVALUE('Rolling Period Table'[Period], 12)
Bước 04
Total Sales = SUM(FactSales[SalesAmount])
Bước 05
Rolling N Months Sales = 

VAR SelectedPeriod = [Selected Period] 
-- Context: User chọn Slicer là "12 Tháng" -> Biến SelectedPeriod lưu giá trị số nguyên: 12.

VAR LastSalesDate = 
    CALCULATE(
        MAX(FactSales[OrderDate]), 
        REMOVEFILTERS('DimDate')
    )
--    MAX: Tìm ngày lớn nhất trong cột OrderDate.
--    REMOVEFILTERS('DimDate'): Chỉ gỡ bỏ bộ lọc từ bảng Date (trục thời gian của biểu đồ), 
--    nhưng vẫn GIỮ NGUYÊN bộ lọc từ các bảng khác (VD: Khu vực, Sản phẩm).
-- Context: 
--    Cần biết ngày bán hàng cuối cùng là ngày nào để chặn biểu đồ không vẽ ra tương lai.
--    Nếu đang chọn Slicer "Miền Bắc" (dừng bán tháng 6), hàm này sẽ trả về 30/06, thay vì 31/12 của toàn công ty.
--      Giả sử data toàn công ty đến 31/12/2024. 
--      Nhưng Slicer chọn "Miền Bắc" (chỉ có data đến 30/06/2024) => Biến LastSalesDate = 30/06/2024.

VAR CurrentDate = MAX('DimDate'[Date])
-- Lấy giá trị lớn nhất của cột Date trong Filter Context hiện tại.
-- Context: Xác định xem điểm dữ liệu đang được tính toán nằm ở tháng nào trên trục X.
--    Tại điểm hiển thị "Tháng 8/2024" trên biểu đồ Line Chart => Biến CurrentDate = 31/08/2024.

RETURN
IF(
    CurrentDate <= LastSalesDate, 
    -- Context: "Tháng đang vẽ trên biểu đồ có nằm trong quá khứ/hiện tại không?"
    --    TH1 (Tháng 8): 31/08/2024 <= 30/06/2024 (LastDate miền Bắc)? -> FALSE.
    --    TH2 (Tháng 5): 31/05/2024 <= 30/06/2024? -> TRUE.

    -- [Nếu TRUE: Tính toán Rolling]
    CALCULATE(
        [Total Sales], -- Measure gốc cần tính
        
        DATESINPERIOD(
            'DimDate'[Date],      -- Cột ngày cần dịch chuyển
            MAX('DimDate'[Date]), -- Điểm neo: Lấy ngày cuối của tháng hiện tại (CurrentDate)
            -SelectedPeriod,      -- Khoảng dịch chuyển: Lùi lại 12 (hoặc N) khoảng
            MONTH                 -- Đơn vị: Tháng
        )
    ),
    --    DATESINPERIOD trả về một bảng danh sách các ngày liên tục trong khoảng lùi 12 tháng.
    --    CALCULATE ép buộc [Total Sales] phải tính tổng trong khoảng thời gian mới này.
    -- Context: Cộng dồn doanh thu của 12 tháng gần nhất lại.
    --    Tại tháng 05/2024. Neo là 31/05/2024.
    --    DATESINPERIOD tạo list ngày từ: 01/06/2023 đến 31/05/2024.
    --    CALCULATE tính tổng tiền trong giai đoạn này.

    -- [Nếu FALSE: Trả về Rỗng]
    BLANK() 
    -- Context: Ẩn đường vẽ trên biểu đồ.
    -- Tại tháng 08/2024 (lớn hơn 30/06/2024), trả về Blank -> Biểu đồ ngắt nét.
)
Bước 06
Rolling N Months Sales LY = 
CALCULATE(
    [Rolling N Months Sales],
    DATEADD('DimDate'[Date], -1, YEAR)
)
Bước 07
Rolling Sales % Growth = 
VAR GrowthRate = DIVIDE(
    [Rolling N Months Sales] - [Rolling N Months Sales LY],
    [Rolling N Months Sales LY],
    0
)
RETURN FormatPercentWithArrow(GrowthRate)
Bước 08
Rolling Sales Variance = 
VAR _Var = [Rolling N Months Sales] - [Rolling N Months Sales LY]
RETURN FormatWithArrow(_Var)
Bước 09
Get Color = GetColorFromText([Rolling Sales % Growth])
Bước 10
Dynamic Trend Title = 
"Rolling " & [Selected Period] & " Months Sales: " & FORMAT(MAX(DimDate[Date]), "MM/yyyy")
Bước 11
Top 5 Products Rolling = 
VAR SelectedPeriod = [Selected Period]
VAR CurrentDate = MAX(DimDate[Date])

-- Xác định Filter Context cho trục thời gian
-- Trả về bảng date chứa range ngày từ (CurrentDate - N Month) đến CurrentDate
-- Tại datapoint T12/2024, Period=3 -> Bảng Date chứa 01/10/2024..31/12/2024
VAR PeriodDates = DATESINPERIOD(DimDate[Date], CurrentDate, -SelectedPeriod, MONTH)

-- Chỉ giữ lại 5 dòng có giá trị cao nhất, loại bỏ các dòng không cần thiết
VAR Top5Products = 
    TOPN(
        5, 
        -- Context Manipulation: Sử dụng CALCULATETABLE để áp dụng PeriodDates Filter vào danh sách sản phẩm
        -- Chỉ xếp hạng dựa trên doanh thu của giai đoạn Rolling này
        CALCULATETABLE(VALUES(DimProduct[ProductName]), PeriodDates), 
        [Total Sales], 
        DESC
    )

-- Thực hiện tính toán phức tạp trên tập dữ liệu đã thu gọn
-- ADDCOLUMNS là Iterator, lặp qua 5 dòng kết quả của bước trên
VAR Top5WithDetails = 
    ADDCOLUMNS(
        Top5Products,
        
        -- Context Transition: Row Context (Product Name) chuyển thành Filter Context
        -- Kết hợp với PeriodDates Filter để tính Sales TY
        "SalesTY", CALCULATE([Total Sales], PeriodDates),
        
        -- Time Intelligence Logic: Dịch chuyển Filter Context của bảng PeriodDates lùi 1 năm
        -- Filter Date (2024) -> Filter Date (2023) áp dụng cho cùng sản phẩm đó
        "SalesLY", CALCULATE([Total Sales], DATEADD(PeriodDates, -1, YEAR)),
        
        -- Re-ranking: Tính lại Rank chính xác trong phạm vi 5 sản phẩm này
        "Rank", RANKX(Top5Products, CALCULATE([Total Sales], PeriodDates), , DESC, DENSE)
    )

RETURN
-- Tạo chuỗi kết quả hiển thị
CONCATENATEX(
    Top5WithDetails,
    VAR GrowthPct = DIVIDE([SalesTY] - [SalesLY], [SalesLY], 0)
    VAR Arrow = IF(GrowthPct >= 0, "▲", "▼")
    VAR GrowthText = Arrow & FORMAT(ABS(GrowthPct), "0%")
    
    RETURN
    -- String Format: Rank. Name: Sales (Growth)
    [Rank] & ". " & [ProductName] & ": " & FormatKMB([SalesTY]) & " (" & GrowthText & ")",
    -- Delimiter: Ký tự xuống dòng
    UNICHAR(10),
    -- Sort Order: Sắp xếp dòng theo Rank tăng dần
    [Rank], ASC
)

Leave a Comment