A plan in any cut — without rebuilding the report
Sales planning on DAX — a plan that re-assembles in minutes
The manager opens one sheet and turns the filters. Plan and actual are comparable in any cut — from team lead down to client and day. And revising the plan is an edit in Google Sheets, not a report rebuild.
The plan is entered in Google Sheets in two modes, normalized in Power Query, and spread by three allocations — month→day, brand→SKU, rep→client. Then a star schema in Power Pivot and plan/actual measures in DAX.
01How it worked before us
The company revised its sales plan often: changing brand targets, shifting load between reps, reassigning channel owners. Every such revision ran into the same wall.
- To reconcile "plan vs actual" by brand in a channel, someone assembled the data from various exports by hand every week. Slow and error-prone.
- A new question from management ("show me by team lead for the quarter", "how much of the plan is hanging on this client") meant digging through spreadsheets again — there was no ready cut.
- Reassigning a plan from one rep to another meant rewriting history across several files without breaking anything. In practice that's exactly where it broke.
In this company the plan is a living document: management revises it through the year, shifts targets between reps and brands, changes owners. Before the project, planning lived in a set of scattered spreadsheets assembled by hand every week.
- Plan revision = rebuilding everything. Every change meant manually redistributing targets across departments and brands — a huge time sink.
- No relational links — no rollup. The plan and actual tables weren't connected. "Plan vs actual by brand in a channel" was assembled by hand weekly.
- The visuals were weak. Without a data model any summary is a static sheet for one question. A new cut meant digging through exports again.
- Reassigning an owner — a disaster. Rewriting history across several tables without breaking anything — a source of errors and lost time.
- Different planning depth. Some brands were planned in detail, by item; others in broad strokes by brand and channel. Reconciling the two approaches into one actual-comparable picture by hand is nearly impossible.
02What we built
Home screen: plan and actual by people
The manager opens one sheet and turns the filters. Plan and actual are always comparable — because they're computed from one model and filtered by the same relationships. The color in the "% attainment" column shows at a glance who's behind.
| Rep | Team lead | Plan, $ | Actual, $ | % attainment | Plan, units | Actual, units |
|---|
* Rep and lead names are placeholders. Scale: ≥100% · 85–99% · <85%.
The flow is familiar: pick a quarter — see who's behind; pick a team lead — the picture for their team; add a brand or channel — narrow to a direction. And the report isn't rebuilt — one screen closes dozens of questions.
Allocating the plan to a client
The plan is set on a rep and a brand — there's no client in the plan. But a manager often needs to understand which clients a given rep's plan is "hanging" on. The model spreads the rep's plan across their clients in proportion to each client's share of actual sales — pick a rep and see how their target distributes.
Rep's plan for the quarter: · spread across clients by their share of actual sales.
| Client | Actual sales, $ | Share | Allocated plan, $ | % attainment |
|---|
* Client names hidden. The same method spreads the plan onto any cut where actual sales exist.
Revising the plan — an edit in Google Sheets
Management keeps the plan not in the report and not in the ERP, but in Google Sheets — in whatever form is convenient to plan in. Some brands are planned in detail by item, others in broad strokes by brand and channel. Both live in one workbook on different sheets.
| Brand | Channel | Rep | Month | Plan, $ |
|---|---|---|---|---|
| [Brand 1] | Pharmacy chains | [Rep A] | Apr 2026 | 23,700 |
| [Brand 1] | Distributors | [Rep A] | Apr 2026 | 15,800 |
| [Brand 2] | Marketplaces | [Rep B] | Apr 2026 | 11,800 |
| [Brand 3] | Pharmacy chains | [Rep C] | Apr 2026 | 18,400 |
From there the model does the rest: it spreads the monthly plan across days, allocates the summary target to specific items by their share of sales, binds it to the calendar, and recomputes "% attainment". From management — only the numbers in the table.
How the data refreshes
Preparing actuals from QuickBooks is an ordinary export to a file — no developer, no customization. Everything else is on the model side. One click pulls both the fresh plan from Google Sheets and the fresh actual from the export.
What it made possible
- One screen for dozens of questions. Lead, rep, brand, channel, quarter — any cut without rebuilding the report.
- The plan shows up where it was never set. Allocating to client and item shows the plan in cuts where no direct plan exists.
- Plan revision — minutes. An edit in Google Sheets and one refresh instead of a week of manual rollup.
- Handover. The plan logic lives in the model and in Google Sheets, not in one employee's head.
Architecture
The principle is the same as in the client's other modules: pull only raw data out of QuickBooks with a simple export, and keep all the logic — normalization, plan allocation, plan/actual computation — in the Excel model. The plan, meanwhile, is set not in QuickBooks and not in code, but in Google Sheets.
from Google Sheets QuickBooks export folder
— sales actuals
QuickBooks here is neither an integration nor a customization: the manager exports sales with the standard "to file" button. All the "dirt" in those exports (junk rows, casing, naming inconsistencies, total rows) is cleaned up inside the M code. The plan doesn't pass through QuickBooks at all — it lives in Google Sheets and is pulled straight from there.
Two plan-entry modes — one output table
Management plans differently depending on the brand, and breaking that habit would be a mistake. So Google Sheets has two sheets, and the model brings them to a common shape itself.
The final Plans query is a merge of two normalized branches into a table with identical columns: Brand, Channel, Rep, Date, Quarter, SKU, Plan ($), Plan (units).
-- "Plans" query (final): union of the two planning modes let Source = Table.Combine({ #"Plans without item detail", -- summary: brand × channel × rep #"Plans with item detail" -- detailed: by SKU }), Types = Table.TransformColumnTypes(Source, { {"Sales plan ($)", type number}, {"Sales plan (units)", type number} }) in Types
Parsing the multi-row Google Sheets header
In the plan table management keeps data in a "wide" form that's easy on the eye: brand/SKU on the left, and the header is several rows (month, channel, rep, plan type $/units). The model needs a normal (long) form. The trick: glue the header rows into one composite heading via an @ delimiter, unpivot the table, then split the heading back out.
-- Build a composite heading from several header rows Headers = Table.SelectRows( Table.Transpose( Table.FirstN(Cleaned, 4) ), -- 4 header rows each not List.IsEmpty(List.RemoveMatchingItems(Record.FieldValues(_), {"", null})) ), Combined = Table.CombineColumns( Table.FillDown(Headers, Table.ColumnNames(Headers)), Table.ColumnNames(Headers), each Text.Combine(_, "@"), "Combined" )[Combined], -- Unpivot "wide" to "long" and split the heading back out Long = Table.UnpivotOtherColumns(Tbl, {"Brand", "Item.SKU", "Item"}, "Attribute", "Value"), Parsed = Table.SplitColumn(Long, "Attribute", Splitter.SplitTextByDelimiter("@", QuoteStyle.Csv), {"Plan month", "Sales channel", "Rep", "Plan type"})
Allocation #1: month → day
Management sets the plan by month, but actuals arrive by day. To make them comparable in any cut (day, week, month, quarter), the monthly plan is "spread" across days: the query joins the Calendar on the first day of the month and divides the value by the number of days.
-- Join Calendar; daily plan = monthly ÷ days in month WithCalendar = Table.NestedJoin(Parsed, {"Plan month"}, Calendar, {"FirstDayOfMonth"}, "Cal", JoinKind.LeftOuter), Expanded = Table.ExpandTableColumn(WithCalendar, "Cal", {"Date", "Quarter", "DaysInMonth"}), Daily = Table.AddColumn(Expanded, "Daily Plan", each [Value] / [DaysInMonth])
This step turns the compact GS plan into a daily table: with several brands, channels and reps, a year adds up to over a million rows. But from then on any period is a plain sum, with no "but February has 28 days" caveats.
Allocation #2: brand → SKU (by share of sales)
On the summary sheet the plan is set on a brand and channel, with no item split. To compare it to actuals by SKU, we spread the plan in proportion to each SKU's share of the brand's sales over the last 5 months — the plan "self-tunes" to the current product mix. First a separate query computes the weights.
-- SKU weight within a brand (sales over the last 5 months) ByBrand = Table.Buffer( Table.Group(Recent, {"Brand"}, {{"Brand $", each List.Sum([#"Sales ($)"]), type number}}) ), BySKU = Table.Group(Recent, {"Item.SKU", "Brand"}, {{"$", each List.Sum([#"Sales ($)"]), type number}}), WithShare = Table.AddColumn(Expanded2, "$ share", each [#"$"] / [#"Brand $"], type number)
Then the summary plan is multiplied by these weights — the brand target is broken out into specific SKUs, and via the average price also converted into units.
-- Brand plan → SKU plan in proportion to share of sales PlanUSD = Table.AddColumn(Expanded3, "Sales plan ($)", each [Daily Plan] * [#"$ share"], type number), PlanUnits = Table.AddColumn(PlanUSD, "Sales plan (units)", each [#"Sales plan ($)"] / [Unit price], type number)
Relational model: a star instead of manual rollups
The main thing missing before was relationships. Now the Plans table is the fact table at the center of a star, surrounded by lookups that filter it automatically.
Relationships give the whole point of the exercise: a filter on any lookup automatically reaches both the plan and the actual. Pick a team lead — plan and actual recompute across all their reps; pick a quarter — both tables filter. The Rep → Team lead relationship gives roll-up along the hierarchy with no manual summary.
Allocation #3: rep's plan → client (dynamically, in DAX)
The third allocation works not at refresh but at query time. The plan is set on a rep, brand and channel — there's no client in the table. But a manager needs a cut by client. The data has no such answer — a measure builds it, spreading the plan in proportion to each client's share of actual sales.
-- [Plan $] — spread the plan onto the current cut by share of actual IF( [Sold $], SUM('Plans'[Sales plan ($)]) * DIVIDE( [Sold $], CALCULATE([Sold $], ALL('Dim_Clients'[Client])), 0 ) )
The measure takes the plan in the current context and multiplies by the client's share of sales. For a single client DIVIDE returns its share; with no client filter the share equals one — and the measure returns the full plan. The same trick spreads the plan onto any cut with no direct plan.
Plan for any period and % attainment
The plan is brought to daily granularity and linked to the Calendar, so any period is a matter of a filter. Quarterly measures are an ordinary CALCULATE with month bounds, and plan attainment is the ratio of actual to plan, correct at any level of detail.
-- [Plan $ 2026 Q2] CALCULATE( SUM('Plans'[Sales plan ($)]), FILTER( ALL('Calendar'), YEAR('Calendar'[Date]) = 2026 && MONTH('Calendar'[Date]) >= 4 && MONTH('Calendar'[Date]) <= 6 )) -- [% of plan $] and [% of plan units] DIVIDE( [Sold $], [Plan $] ) DIVIDE( [Sold units], [Plan units] )
03Timeline and outcome
| Stage | From start |
|---|---|
| Audit of plan and actual sources, agreeing two entry modes | Start |
| Normalizing the plan from Google Sheets, month→day and brand→SKU allocation | ≈ 1 mo |
| Relational model: plan/actual links, Rep → Team lead hierarchy | + 2–3 weeks |
| Plan/actual measures, client allocation, quarterly cuts | ≈ 2 mo from start |
A similar problem on your side?
Tell us — in 30 minutes we'll figure out what's possible.
