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.
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 ERP report showed today's stock. It didn't answer "when will it run out" — that was computed by hand in Excel, brand by brand.
- Purchasing parameters (lead time, minimum stock) lived in the manager's head or a personal spreadsheet. The manager left — the logic left with them.
- Partner returns distorted the daily-average stats. "Eyeball it" doesn't scale.
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:
- Stockout gaps. By the time the team noticed an item was missing, sales were already lost. Recovering through a rush order means a few extra weeks because of overseas logistics.
- Rigid reporting. An ERP report is built once and for all. A new question from management — reworking the form is slow and expensive.
- "Out of stock" isn't about zero. For a wholesaler an item is "gone" when it drops below a comfortable threshold (e.g. below the minimum shipment). A different threshold for different SKUs.
- Not every replenishment starts a new period. If a partner returned goods — that's an accounting event, not a resumption of sales. Counting daily averages from that "replenishment" corrupts the calculation.
- ERP customization — slow and expensive. Every refinement of business logic through a developer is weeks and an invoice.
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.
| 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.
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.
| SKU | Brand | Lead, days | Target stock, days | Zero threshold | Flag threshold |
|---|---|---|---|---|---|
| SKU-A01 | [Brand 1] | 90 | 60 | 5 | 20 |
| SKU-A02 | [Brand 1] | 75 | 45 | 3 | 15 |
| SKU-B01 | [Brand 2] | 120 | 90 | 10 | 40 |
| SKU-C01 | [Brand 3] | 60 | 30 | 2 | 10 |
What lives in this sheet:
- Lead time — the typical time from order to arrival. Changes when the supplier or route changes.
- Target stock — the target number of days of comfortable stock after arrival.
- Zero threshold — below which stock level the item counts as "absent" in the frame logic.
- Replenishment flag threshold — at which stock level the "amber" / "red" lights up.
How the data refreshes
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
- Portfolio-wide visibility. Scroll a filter by brand, ABC, status — see the whole cut with no calls.
- Early warning. The 30/(31–90) trend highlights items that are accelerating — the buyer sees the risk before stock drops below threshold.
- Consistency. The rules are identical for every SKU — no "this one item is always counted separately".
- Handover. The manager leaves — the parameters stay in Google Sheets. A new person sees the logic from day one.
Architecture
(periodic ETL) Google Sheets → straight
into Power Query
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 parameters (specific to each SKU and warehouse) live in Google Sheets:
| Parameter | What 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, days | Average time from order to arrival at the warehouse |
Target stock, days | How many days of comfortable stock to hold beyond the lead time |
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:
| Component | From |
|---|---|
| Consumption over the lead time | daily sales × Lead time, days |
| Target stock after arrival | daily sales × Target stock, days |
| Minus — what's already here and in transit | current 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:
- ALL — classification across the whole portfolio. Useful for overall ranking and purchasing priority.
- InBrand — classification within a brand. Useful for assessing a specific brand: even a B-rank item can be a "workhorse" inside a narrow line.
-- 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
| Stage | From start |
|---|---|
| Source audit, agreeing the frame approach | Start |
| Purchasing MVP: stock, sales, basic ABC | ~2 mo |
| Connecting Google Sheets for all parameters | +1 mo |
| End-to-end analytics, planning | 5–6 mo |
| Sales-team incentives off the ERP exports | ~1 year |
A similar problem on your side?
Tell us — in 30 minutes we'll figure out what's possible.
