Cases/ Distribution/ Purchasing

Purchasing without surprises: what the buyer sees every morning

Frame-based purchasing for a distributor — when zero stock isn't empty

One screen, 700 SKUs, replenishment flags. Purchasing parameters live in Google Sheets: change a lead time or a threshold, hit "Refresh" — the model recalculates the whole portfolio.

Raw data from QuickBooks via parquet, inputs from Google Sheets, the model in Power Pivot with 207 DAX measures. The availability frame = the period of real sales — returns don't reset it, dips below the threshold don't count.

Purchasing · auto-calc Frame approach ABC × in-brand Google Sheets inputs Power Pivot · DAX 700 SKUs
Industry Import and wholesale distribution of professional cosmetics · Salons, distributors, retail, marketplaces (Shopify · Amazon · Etsy · Walmart), consignment · ~700 active SKUs
Driver's seat The home screen, where the eye lingers, how the data refreshes, what's changed by hand. The engineering side — the toggle in the top-right corner.
Under the hood Architecture, the frame approach, key DAX measures. To see it through the buyer's eyes — the toggle in the top-right corner.
SKU parameters ~7,000 ▲ one Google Sheet
Time per rule ~2 min ▼ was weeks via the ERP
Stockout gaps ▲ early warnings
ABC cuts 2D ALL × in-brand

01How it worked before us

A buyer at a wholesale company constantly walks between "don't stock it — the client walks" and "over-stock it — cash sits on the shelf". With a 700-SKU portfolio and overseas logistics carrying a 1–3 month lead time, every delay turns into lost sales or an expensive "rush" re-order.

Before our work:

The company is an importer and wholesale distributor of cosmetics. Sales channels: salons, distributors, retail, marketplaces (Shopify, Amazon, Etsy, Walmart), consignment. The portfolio is ~700 active SKUs across three warehouses and several accounts. The problem wasn't "is there stock or not" but how to count inventory for a wholesaler:

02What we built

The purchasing home screen

The buyer opens one file. The home sheet has a table of every active SKU with what's needed for the "order or not" decision. Colors are replenishment status, numbers are the specifics.

Purchasing · status by item data as of May 24 · frame auto-recomputed
~700 SKUs in production · here — 12 synthetic items for the demo
Brand
ABC in-brand
Status
SKU Brand ABC Stock CW Daily Trend 30/(31–90) Days left Inbound To order Status

* SKUs are synthetic. ABC grade is in-brand. Sort: "Urgent" → "Watch" → "OK", within — by descending urgency.

The buyer starts with the "Urgent" filter — items with less stock left than the lead time. Those get decisions first: agreeing a price, placing an order, in rare cases — express logistics. Next, "Watch": borderline cases where the sales trend signals risk.

The frame logic — in plain terms

Not every stock drop means "out". Not every replenishment means "back on sale". The frame logic handles both nuances automatically.

SKU SKU-A01 · availability history at the central warehouse
in stock
dip
in stock
return
in stock
frame 1
frame 2 · return doesn't reset
JanMarMayJulSepNov
Active frame · sales are really happening Dip · below the zero threshold, not counted Partner return · the frame continues

In the "daily sales" measure a return isn't counted as the start of a new period — otherwise it understates the real intensity. A dip below the threshold isn't counted as sales (there was nothing to sell). The accounting is automatic and identical for all 700 SKUs — no need to separately "remember" the quirks of a specific item.

Parameters — in Google Sheets, not in code

The key purchasing parameters are moved out of the ERP into Google Sheets. The manager opens the sheet, changes a number — and on the next "Refresh all" in Excel the model recalculates everything that depends on the changed parameter.

Google Sheets · purchasing parameters (fragment)
SKUBrandLead, daysTarget stock, daysZero thresholdFlag threshold
SKU-A01[Brand 1]9060520
SKU-A02[Brand 1]7545315
SKU-B01[Brand 2]120901040
SKU-C01[Brand 3]6030210

What lives in this sheet:

Changed the supplier, lead time is now 60 days instead of 90 — open Google Sheets, change one number. Two minutes later the model is computing recommendations on the new logic. No report rework, no ERP developer.

How the data refreshes

QuickBooksdaily stock
parquetexport
Excel: "Refresh all"+ GS inputs
Fresh statusacross 700 SKUs

The morning routine is to press one button. In parallel, colleagues might have fixed something in Google Sheets (new supplier, updated lead time) — it all pulls in on a single refresh.

What it made possible

Architecture

Data sources
Store
Excel model
QuickBooks · stockdaily snapshots by entity × SKU — ~973k rows
QuickBooks · salesdetail by date, customer, channel, rep
QuickBooks · expected POswhat arrives at the warehouse and when
Google Sheets (inputs)lead · zero threshold · flag · target stock · prices
Workbook sheetsCalendar · entity lookups · service tables
Parquet tables exported from QuickBooks
(periodic ETL)
Google Sheets → straight
into Power Query
Power Query read parquet + GS · type normalization
Power Pivot ~15 model tables · 207 DAX measures

The key decision — pull only raw data out of the ERP (stock, sales, POs) and compute all the business logic in the Excel model via DAX. That buys two things: rules can change without bothering an ERP developer; report customization takes minutes, not weeks.

The frame approach to stock

The core concept is the availability frame. It's the period during which an item was actually selling (not sitting in returns or below threshold). Daily sales are computed only inside frames.

Frame logic on a timeline (schematic)
in stock
dip
in stock
return
in stock
frame 1
frame 2
Jan 1Mar 1May 1Jul 1Sep 1Nov 1
Above threshold · active sales Below zero threshold · frame not running Return · accounting bump, not a new frame

Frame parameters (specific to each SKU and warehouse) live in Google Sheets:

ParameterWhat it does
Zero threshold at CW (units)Below this value, stock is treated as "absent" — the frame doesn't run
Replenishment flag threshold (units)Fires the "needs ordering" flag — but doesn't reset the current frame
Lead time, daysAverage time from order to arrival at the warehouse
Target stock, daysHow many days of comfortable stock to hold beyond the lead time
Why parameters in Google Sheets, not the ERP? The business has 700+ SKUs and parameters change: a supplier switches and the lead time is now 60 days instead of 90; for a premium brand the zero threshold goes up. Changing that in the ERP is a developer ticket. In GS — two minutes, and on the next model refresh everything recalculates.

Daily sales: rolling windows

The base measure is sales over the last 30 days, with an automatic "right edge": either the current calendar context, or the last actual sales date (relevant if the file is opened on a different day):

-- Units Sold (last 30 days)
VAR BaseDate =
    IF(
        ISFILTERED('Calendar'[Date]) || ISCROSSFILTERED('Calendar'),
        MAX('Calendar'[Date]),
        MAX('Sales'[Date])
    )
RETURN
    CALCULATE(
        [Units Sold],
        DATESINPERIOD('Calendar'[Date], BaseDate, -30, DAY)
    )

In parallel a trend measure is computed — the ratio of the last 30 days to the average monthly intensity 31–90 days ago. That instantly shows whether an item is accelerating, slowing or stable.

-- Sales dynamics: (30d) / (avg month over the 31-90d window)
IF(
    [Units Sold (last 30 days)] &&
    [Units Sold (avg month over 30-90 window)],
    [Units Sold (last 30 days)] /
    [Units Sold (avg month over 30-90 window)] - 1
)

The buyer sees not just "how many days of stock are left" but "where the item is heading". That changes priorities: an accelerating item with a small buffer — order first; a slowing one with a big buffer — can wait.

Stock at the "right" date, per entity

The subtlety that breaks naive measures: daily stock is stored as snapshots on specific dates, but not every SKU has a record for every day. You need the latest known date per SKU, separately for the central warehouse:

-- Absolute stock, CW (central warehouse)
VAR LastDate1 =
    CALCULATE(
        MAX('Stock'[Date]),
        ALLEXCEPT('Stock', 'Stock'[Item.SKU]),
        'Stock'[Entity] = "[Central WH]"
    )
VAR Result =
    CALCULATE(
        SUM('Stock'[Qty]),
        'Stock'[Date] = LastDate1,
        'Stock'[Entity] = "[Central WH]"
    )
RETURN Result

For the aggregate picture (all warehouses) — a different pattern: SUMX over SUMMARIZE by entity and SKU, with LASTDATE inside. Each "entity × SKU" combination gets its own latest record:

-- End-of-period stock (aggregate across warehouses)
SUMX(
    SUMMARIZE('Stock', 'Stock'[Entity], 'Stock'[Item.SKU]),
    CALCULATE(
        SUM('Stock'[Qty]),
        LASTDATE('Stock'[Date]),
        'Stock'[Qty] <> BLANK()
    )
)

Days of cover and the recommendation

With stock and daily sales in hand, we compute a simple but informative metric:

-- Months of cover
DIVIDE(
    [End-of-period stock],
    [Units Sold (last 30 days)],
    "infinity"
)

The order-quantity recommendation is assembled from three terms:

ComponentFrom
Consumption over the lead timedaily sales × Lead time, days
Target stock after arrivaldaily sales × Target stock, days
Minus — what's already here and in transitcurrent stock + expected POs

If the result is negative — no order needed. If positive — it's a "raw" recommendation the manager cross-checks against the ABC class and the trend measure.

ABC analysis: two-dimensional

The model holds two versions of the ABC classification, precomputed (in the ETL stage) and placed in the ABC ALL table:

-- In-brand grade (via a measure rather than a direct column —
-- so it reacts to empty sales in context)
IF([Units Sold],
    SUM('ABC ALL'[Percentage InBrand])
)

The buyer looks first at the intersections: A by ALL and A by InBrand with zero stock — an item where the loss is immediately visible.

Inputs from Google Sheets

One Google Sheet — two cases. The same sheet that holds the frame parameters (lead time, thresholds, target stock) also holds the pricing parameters (RRP, wholesale, price types). Power Query pulls it as an ordinary table:

-- Prices from GS (simplified)
let
    Source = Csv.Document(
        Web.Contents("https://docs.google.com/spreadsheets/d/.../export?format=csv&gid=..."),
        [Delimiter=",", Encoding=65001, QuoteStyle=QuoteStyle.Csv]),
    Header = Table.PromoteHeaders(Source),
    Types  = Table.TransformColumnTypes(Header, {
        {"Item.SKU",            type text},
        {"Lead time, days",     Int64.Type},
        {"Zero threshold CW",   Int64.Type},
        {"Flag threshold",      Int64.Type},
        {"Target stock, days",  Int64.Type}
    })
in
    Types

In DAX measures the parameters are pulled in via LOOKUPVALUE or relationships to the fact tables by SKU. A value changes in GS — on the next "Refresh all" it lands in the model and recalculates everything that depends on it.

03Timeline and outcome

StageFrom start
Source audit, agreeing the frame approachStart
Purchasing MVP: stock, sales, basic ABC~2 mo
Connecting Google Sheets for all parameters+1 mo
End-to-end analytics, planning5–6 mo
Sales-team incentives off the ERP exports~1 year
The purchasing module came first. The frame approach was developed and validated on it. After that, the same infrastructure (Google Sheets as input, parquet as source, DAX measures as logic) was used for every later module — pricing, sales, incentives.

A similar problem on your side?

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

view mode