Module 4 · Data Analyst Capstone: End-to-End BI Project
Model and measures
Build a model that joins tables at the right grain, look up the cost price in force on each sale date, compare actuals with monthly targets, and test the core measures.
About 25 minutes
The problem
Two traps wait between a clean sales table and a correct profit figure.
The cost trap. Voltline's costs went up 18% on 1 January 2026. Join sales to cost_prices on product code alone and every sale matches two cost rows: 27,588 lines become 55,176, and every total doubles. Pick just the latest cost instead, and 2025's sales are costed at 2026 prices: the first half of 2025 shows a gross margin of −2.7%, as if Voltline sold everything at a loss.
The target trap. Targets are one number per store per month. Join them to sales lines and each target is repeated once per line, so a store's target appears hundreds of times over.
Both are grain problems, and both are invisible unless you check. This lesson builds the model that avoids them.
The concept
The star schema
| Table | Grain | Key | Role |
|---|---|---|---|
sales_clean | one till line | txn_id + line_no | fact |
stores | one store | store_code | dimension |
products | one product | product_code | dimension |
Date | one day | Date | dimension |
targets | one store per month | store_id + month | a second fact, at a coarser grain |
Looking up a cost that changes over time
Each sale needs the cost whose effective_from is the latest one on or before the sale date. That's a "range lookup":
| Tool | How |
|---|---|
| SQL | a correlated subquery: (SELECT unit_cost FROM cost_prices cp WHERE cp.product_code = s.product_code AND cp.effective_from <= s.sale_date ORDER BY cp.effective_from DESC LIMIT 1) |
| Excel | XLOOKUP with match mode -1 (exact or next smaller) on a key of product and date, or MAXIFS to find the effective date, then a lookup |
| pandas | pd.merge_asof(sales.sort_values("sale_date"), costs.sort_values("effective_from"), left_on="sale_date", right_on="effective_from", by="product_code") |
| Power BI | a merge in Power Query, or the calculated column below |
The Power BI calculated column, on sales_clean:
DAX
Unit Cost =
VAR Code = sales_clean[product_code]
VAR SaleDate = sales_clean[sale_date]
VAR Effective =
MAXX (
FILTER ( cost_prices, cost_prices[product_code] = Code && cost_prices[effective_from] <= SaleDate ),
cost_prices[effective_from]
)
RETURN
MAXX (
FILTER ( cost_prices, cost_prices[product_code] = Code && cost_prices[effective_from] = Effective ),
cost_prices[unit_cost]
)Whichever you use, check that the row count doesn't change after the lookup.
Comparing with targets: aggregate first
Total the sales to store and month, then compare with the targets. In Power BI, relate targets to stores (via store_id) and to Date (via a month-start date column you add to targets), and write Target = SUM ( targets[net_sales_target] ). The measure only makes sense at month level or above. On a single day it would show the whole month's target.
The core measures
DAX
Net Sales = SUM ( sales_clean[net_sales] )
Gross Profit = SUMX ( sales_clean, sales_clean[net_sales] - sales_clean[qty] * sales_clean[unit_cost] )
Gross Margin % = DIVIDE ( [Gross Profit], [Net Sales] )
Target = SUM ( targets[net_sales_target] )
Target Attainment % = DIVIDE ( [Net Sales], [Target] )
Transactions = DISTINCTCOUNT ( sales_clean[txn_id] )Example
Gross margin by category for January to June 2026, with costs looked up correctly:
| Category | Net sales (₦m) | Gross margin |
|---|---|---|
| Phones | 688.5 | 9.3% |
| Solar & power | 676.7 | 18.4% |
| Laptops | 369.2 | 9.6% |
| Home appliances | 341.7 | 13.2% |
| Accessories | 57.1 | 44.4% |
Phones and solar bring in almost the same sales, but solar's margin is twice as high, so solar now earns about twice the gross profit of phones. Accessories are tiny in sales but earn 44p in every naira. Keep that in mind for lesson 5.
Walkthrough
- Add
unit_costto the clean sales with a range lookup in your chosen tool. Check the row count is still 27,588. - Calculate gross margin for January to June 2025. It should be about 12.9%. If you get −2.7%, your lookup used 2026 costs for 2025 sales.
- Build the model: relate the fact to stores, products and a date table, and the targets to stores and dates (at month start).
- Write the six core measures (or the equivalent SQL or pandas summaries) and format them.
- Test them: total net sales should be ₦5,811,600,950 (from lesson 3), and target attainment for one store and month should match a hand calculation.
Practice
Practice
What was Voltline's gross margin % for January to June 2026? One decimal place.
Practice
What was the gross profit of the Solar & power category for January to June 2026? (A rounded figure is fine.)
Practice
Across all stores, what was Target Attainment % for January to June 2026? One decimal place.
Check your understanding
Answer every question to check.