Cases/ Pharma distribution/ Planning

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.

Plan / actual 3 plan allocations Lead → rep → client Google Sheets inputs Power Query · M Power Pivot · DAX Relational model
Industry Import and wholesale distribution of pharmaceuticals and parapharmaceuticals · Pharmacy chains, distributors, marketplaces, hospital segment · ~2,000 active SKUs, several brands and reps under two team leads
Driver's seat The plan/actual home screen, allocating the target to a client, how the plan is revised. The engineering side — the toggle in the top-right corner.
Under the hood Architecture, parsing the Google Sheets header, the three plan allocations and key DAX measures. To see it through the manager's eyes — the toggle in the top-right corner.
Plan revision ~minutes ▼ was weeks of manual rollup
Allocation levels 3 month→day · brand→SKU · →client
Reassign owner 1 cell ▲ edit in Google Sheets
Plan/actual cuts any lead · rep · brand · client

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.

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.

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.

Plan / actual by rep 2026 · structure matches the working model
synthetic data · laggards floated to the top
Period
Team lead
Brand
Channel
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.

Allocating a rep's plan to clients Q2 2026 · plan spread by share of actual
the client totals equal the rep's plan — the plan isn't lost or double-counted
Rep

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.

Google Sheets · "summary plan" sheet (fragment)
BrandChannelRepMonthPlan, $
[Brand 1]Pharmacy chains[Rep A]Apr 202623,700
[Brand 1]Distributors[Rep A]Apr 202615,800
[Brand 2]Marketplaces[Rep B]Apr 202611,800
[Brand 3]Pharmacy chains[Rep C]Apr 202618,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.

Reassigning an owner is changing a label. Reassigning a plan used to mean rewriting history across several tables. Now a rep is just a value in a column: change it in Google Sheets, hit "Refresh all" — and plan, actual and percentages re-assemble themselves. No lost rows, no double-counting.

How the data refreshes

Google Sheetsplan · 2 sheets
QuickBookssales export to a file
Excel: "Refresh all"normalize + allocate
Plan / actualin any cut

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

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.

Data sources
Transfer
Excel model
Google Sheets · detailed planbrand × SKU × month × rep
Google Sheets · summary planbrand × channel × rep × month
QuickBooks · salessimple export — actuals and weights for allocation
Workbook sheetsCalendar · lookups for brands, channels, reps (→ lead)
Web.Contents (GS) the plan is pulled straight
from Google Sheets
QuickBooks export folder
— sales actuals
Power Query parse GS header · normalize · allocate plan
Power Pivot star schema + Calendar · plan/actual measures in DAX

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.

"By item" sheetplan by SKU
"Plans" tablesingle schema
"Summary" sheetplan by brand × channel

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"})
Why this way. Management keeps filling the plan in the familiar "wide" table, where reps and plan types run across the header. No one is forced to enter data "the way the database likes". Bringing it to a normal form is the M code's job, not a human's.

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)
Why share of sales, not a fixed coefficient. The sales mix within a brand shifts: seasonality, new launches, items being phased out. Anchoring to the last 5 months' sales means the summary plan automatically flows to where sales are actually happening now. No one recomputes the split by hand.

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.

★ Star schema of the planning module
Dim_BrandsBrand
Calendardate · month · quarter · week
Dim_Channelssales channel
Dim_Repsrep → team lead
Plansbrand · channel · rep · date · SKU · plan $/units
Dim_ProductsSKU · title

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.

The first two allocations (month→day, brand→SKU) are materialized in the M code — computed once at refresh. The third (rep→client) is dynamic — recomputed by a measure for the current filter. The rule is simple: what doesn't depend on the cut — precompute; what depends on the user's choice — leave to a measure.

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] )
What this buys at plan-revision time. Management edits the numbers in Google Sheets (in either of the two modes). On the next "Refresh all" both branches re-normalize, the plan is spread across days and SKUs, relationships carry it to the needed cuts, and the measures recompute "% attainment". Reassigning a target is changing a column label in GS, not rewriting history.

03Timeline and outcome

StageFrom start
Audit of plan and actual sources, agreeing two entry modesStart
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
The planning module landed on the client's existing infrastructure: Google Sheets as the place to enter management parameters, QuickBooks exports as the source of actuals, Power Query + Power Pivot as the logic layer. The same stack had earlier covered purchasing and pricing — planning reuses it wholesale.

A similar problem on your side?

Tell us — in 30 minutes we'll figure out what's possible.

view mode