D-03
S&OP / Demand Review
The monthly cycle review, as one pattern: a top-down wireframe on top of a plan snapshot fact. The room reads it in one direction — the headline first, the granularity beneath it — and every number on it is scored against the plan that was signed off, not the plan as it has since been re-edited. This page carries the wireframe, the dimensional design, and the source tables in SAP, JD Edwards, Dynamics 365, Infor M3, Oracle EBS, and Oracle Fusion.
There is a reference build of this pattern: the S&OP / Demand Review dashboard in the showcase renders it over frozen, simulated data — not a live client system. Its tiles are this wireframe over fifteen frozen, simulated plan cycles — scored against the plan each cycle locked, with the flattering number against the latest estimate published beside it rather than instead of it.
Last verified September 2026.
What it answers
One question, asked once a month: is the plan we agreed last cycle still the plan? Everything else on the board is a way of answering it — how much demand has moved away from the number, whether supply can still meet it, and how much the plan itself has been rewritten since.
That only works if the plan being scored is fixed. Two variants get named and kept apart everywhere on this pattern: the consensus plan — the constrained number the room signed off and locked at the end of the cycle — and the latest estimate — the unconstrained current view, edited continuously between cycles. Every tile says which one it shows. Score the latest estimate and the review becomes self-congratulatory by construction: the plan is always close to actual, because it was moved toward actual all month.
This is the same laundering trap the sell side has with promise dates — the date-basis switch in the OTIF entry — where measuring against the current promise makes every reschedule disappear. Same failure, different column: an unlocked baseline flatters the number that is supposed to hold the room accountable.
One boundary to keep clean: this is the executive cycle review, at family grain, once a month. The planner instrument underneath it — item × location, worked continuously, error ranked onto a named worklist — is the Plan vs Actual pattern. Same discipline about locked baselines, different grain and different audience.
The wireframe
Top-down: executive summary first, granularity below, read in one direction. It suits a review with a fixed agenda and a fixed audience: the headline lands in the first thirty seconds, and the plan grid at the bottom is where the argument actually gets settled.
S&OP demand review dashboard wireframe: cycle filters, four KPI tiles, consensus plan versus latest estimate versus actual by plan month, demand gap by family beside supply gap by site, and the family by month plan grid
Layout basis
- L-01 Top-Down Hierarchy — executive summary first, granularity below — the layout the monthly review room reads top to bottom
- L-04 Tabbed Views — the alternative when the review walks fixed steps — a demand tab, a supply tab, a reconciliation tab over the same fact
What each region reads from the schema below:
- Filters —
dim_plan_cycleis the first filter and the most consequential one — it selects which locked plan the whole board scores against. Family and site come fromdim_productanddim_location. - KPI row — four tiles over the selected cycle, each labeled with the plan variant it reads: attainment and churn are ratios recomputed from summed components, drift and supply gap are signed quantities. A tile that says only "plan accuracy" is a tile nobody can defend.
- Plan vs LE vs actual — three series over
dim_date'splan_monthrole — the locked consensus plan, the unconstrained latest estimate, and actuals attaching as months close. The gap between the first two is the conversation; drawing only one of them hides it. - Demand and supply gaps — the same locked plan as the denominator on both sides —
dim_productfamily for demand,dim_locationsite for supply. Two panels, one baseline, so the demand and supply halves of the meeting cannot quietly be arguing about different numbers. - Plan grid — the fact's own grain, pivoted family × plan month with locked plan, latest estimate, and actual stacked in each cell — the drill destination, and the artifact the room marks up.
The star schema underneath
The fact is a periodic snapshot, but the snapshot is of a plan, not a population: at the close of every cycle, the whole forward plan is photographed and stored under that cycle's key. That is what makes the lock enforceable — last cycle's rows are immutable history, and no edit made since can reach back into them.
Grain one row per plan cycle × product family × site × plan month
Stored components
- consensus_plan_qty / consensus_plan_value
- latest_estimate_qty / latest_estimate_value
- supply_plan_qty / supply_plan_value
- prior_cycle_plan_qty / prior_cycle_plan_value
- actual_demand_qty / actual_demand_value
Foreign keys
- → dim_plan_cycle
- → dim_date
- → dim_lag
- → dim_product
- → dim_location
- cycle_month
- lock_date
- cycle_number
The version axis — one row set per monthly cycle, locked at sign-off; scoring always joins back to the cycle that was agreed, never the latest edit.
- months_out
- is_scored_lag
months_out is plan month minus cycle month — it depends on both keys, so it is its own dimension (or a derived fact column), never an attribute of the date dimension.
- site
- region
The supply-side cut — where the plan and the response diverge.
- plan_month
- fiscal_period
The month being planned — plan months attach here; the horizon bucket lives on dim_lag, not here.
- family
- franchise
- planner
S&OP runs at family grain; the item detail lives on the Plan vs Actual pattern, not here.
Two rules carry the design. Store every variant as its own component: consensus, latest estimate, supply plan, prior cycle, and actual all sit side by side on the row, so drift, gap, churn, and bias are subtractions at query time rather than four differently-built tables that will disagree by March. Store quantity and value in parallel: the demand side runs the review in units and finance runs it in value, and both are legitimate. Carry both components and compute each view from its own pair — never convert a ratio computed in units into a value number by multiplying it by a price. Mix effects make that quietly wrong every time — churn included, which is why the prior cycle carries a value component too.
The consequence of photographing the whole forward plan every cycle: the quantities sum within a cycle, never across cycles. Each cycle re-states the same plan months, so a total that spans two cycles counts those months twice. The cycle is the first filter on this board for exactly that reason.
Measures & additivity
Five measures, each a projection of the stored components, none of them stored:
| Measure | Formula | Additivity | Note |
|---|---|---|---|
| plan_attainment_pct | sum(actual_demand_qty) / nullif(sum(consensus_plan_qty), 0) | Non-additive | Recomputed from summed components at every grain — never the average of per-family attainment. No published definition of plan attainment has been located to inherit — the circulating constructions disagree on overshoot and on which plan version sits in the denominator — so this uncapped form is an authored choice, with the capped alternative (Σ min(actual, plan) ÷ Σ plan, where a surplus cannot pay for a shortfall) named rather than mixed in. |
| latest_estimate_drift_qty | sum(latest_estimate_qty − consensus_plan_qty) — one plan cycle at a time | Semi-additive | The unconstrained current view against the locked plan — signed, so offsetting drift nets out visibly by design; pair with the absolute version in the detail. Sums across families and sites within a cycle; never across cycles — each cycle re-states the same plan months. |
| supply_gap_qty | sum(supply_plan_qty − consensus_plan_qty) — one plan cycle at a time | Semi-additive | The supply response measured against the same locked plan the demand side is scored on — one denominator, one conversation. Positive means the supply plan exceeds the locked demand plan. Sums across families and sites within a cycle; never across cycles — each cycle re-states the same plan months. |
| plan_churn_pct | sum(abs(consensus_plan_qty − prior_cycle_plan_qty)) / nullif(sum(prior_cycle_plan_qty), 0) | Non-additive | How much the plan moved cycle over cycle — absolute deltas summed before dividing, so offsetting edits don't hide churn. |
| actualized_bias_pct | sum(consensus_plan_qty − actual_demand_qty) / nullif(sum(actual_demand_qty), 0) | Non-additive | Signed, over actualized months only — the direction the room habitually misses, not the size of single misses. |
The one that bites hardest: never average attainment across families. A family at 300% of plan and a family at 20% do not average to anything a business should act on. Sum the actuals and sum the plan first, then divide once — at every grain the board rolls up to. The same discipline the KPI Dictionary applies to service metrics applies to every ratio here.
And the targets: the attainment line drawn on this board is the number the business agreed to in its own planning process, labeled as exactly that. There is no external benchmark for plan attainment worth drawing next to it — a line with no provenance invites the room to argue about the line instead of the plan.
On Databricks AI/BI
Rendering notes for building this wireframe on Databricks AI/BI dashboards. Where a capability below comes from hands-on checking rather than documentation, it was verified in one workspace on 2026-08-24 — a point-in-time observation, not a vendor commitment; re-check in your own workspace before committing a design to it.
| Need | Status | Note |
|---|---|---|
| Locked plan, latest estimate and actual distinguishable in one chart | Native | The line-chart editor has a Pattern section that sets Auto, solid, dashed or dotted per Y-series, verified rendering in one workspace on 2026-08-24. Dash the unlocked estimate and leave the locked plan and the actual solid, so the provenance of each line is carried by the line itself. |
| One plan cycle on the page at a time, never two of them combined | Native | There is a rule about which filter mechanism to use. Make the cycle a parameter rather than a field filter: a parameter is substituted into the dataset SQL before aggregation and always re-runs the query, while a field filter on a dataset of 100K rows or fewer re-slices rows already returned in the browser. The parameter path is what keeps every tile reading exactly one cycle's rows. |
| Assumption markers on the plan chart, dated to the cycle where the assumption changed | Workaround | The path that was tested is the horizontal, field-driven annotation: a labeled constant line spanning the whole axis, drawn once per distinct value of the field it reads — run in one workspace on 2026-08-24 against an effective-dated column, which produced full-width lines for both values with no time scoping. There is no point-marker annotation of any kind. The documentation also lists vertical orientation, so a constant vertical line at a cycle-lock date is the untested candidate for a dated marker — exercise it in your own workspace before designing on it. Until then, carry the assumptions as a dated series plotted alongside the plan, or as a companion table beside the chart. |
| Family panels side by side rather than twelve lines in one frame | Native | A per-widget Facet encoding renders small multiples, verified rendering side-by-side panels on a bar chart in one workspace on 2026-08-24. The encoding does not decide how many panels a reader can compare, so keep the panel count disciplined in the dataset behind it. |
Sourcing it from SAP
Table roles. The honest starting point: in an SAP landscape the consensus plan usually does not live in ECC or S/4 at all — it lives in APO or, increasingly, in IBP. What the ERP reference gives you is the mapping layer: /SAPAPO/MATKEY is the APO product master and /SAPAPO/MATLOC the location-product record, and together they tie planning output back to the material and plant keys the rest of the model uses — MARC for the plant-level planning attributes. Supply-side response shows up as PLAF planned orders with MDKP as the MRP document header. Actual demand comes from the sales side: VBAK / VBAP on an orders basis, or VBRK / VBRPon an invoiced basis — which of the two is "actual demand" is a decision to make once and label on the axis.
The structural insight. The planning system holds the current plan, not the plan history — it is built to be overwritten by the next run. So the cycle snapshot is a lakehouse capture: at the lock date, extract the plan in full and write it under that cycle's key. Miss a cycle and it is unrecoverable, which is why the lock calendar is a build prerequisite rather than a scheduling detail.
Sourcing it from JD Edwards
Table roles. JDE is the one system here that keeps the plan in the ERP: F3460 holds forecast quantities keyed by item, branch, and forecast type — and that forecast type is a genuine in-ERP version axis, which is what lets generated and adjusted plans coexist as separate rows rather than one overwriting the other. Planning-run output lands in F3413 (the MRP time series, where the quantity-type dimension gives you demand, supply, and projected on-hand from one narrow table) and F3411 (the action messages planners work after each run); F4801 carries work orders on the make side. Actuals come from F4211 plus F42119 — the union is mandatory, because lines move to history at sales update.
The traps. Forecast-type discipline is a client-by-client question: if the business overwrites the same type each cycle, the version axis is decorative and the snapshot still has to be captured downstream. And everything numeric is encoded on the way in — quantities carry implied decimals, the branch plant is space-padded (TRIM before joining), and dates are Julian CYYDDD integers.
Sourcing it from Dynamics 365
Table roles. ForecastSales holds demand forecast lines per forecast model, item, dimension set, and period — and the forecast model is the version axis, the same role JDE's forecast type plays. The master plan's own output is ReqTrans: every demand and supply the planning run saw per plan version, with the planned orders it generated — the supply-response side of the board. Actuals come from SalesTable / SalesLine on an orders basis, or CustInvoiceJour / CustInvoiceTrans on an invoiced basis.
The traps. Plan versions in D365 are routinely recycled — a static plan copied over a dynamic one keeps the same version name, so the version axis alone will not give you cycle history without a dated capture. Every table is company-partitioned (the DataAreaId quirk), forecast lines hang off a dimension set (InventDim), and open date columns carry sentinel values rather than NULLs (sentinel dates).
Sourcing it from Infor M3
Table roles. Said plainly: the M3 reference carries no forecast table, and this pattern does not pretend otherwise. What it has is MITPLO, the material plan — one row per planned demand or supply transaction per item and warehouse. That is a current-statetable, regenerated by the planning run, so it answers "what does the plan say today" and nothing about what it said last month. MITBAL carries the warehouse balances and planning parameters behind the supply response, MPLINE the inbound purchase orders and MWOHED the manufacturing orders. Actual demand is OOLINE on an orders basis or ODLINE on an invoiced basis.
The traps. Because MITPLOis regenerated, the cycle snapshot is entirely a lakehouse capture — the fact's cycle key exists only downstream, and if nobody schedules the extract at the lock date there is no history to build later. Then the usual M3 mechanics: pin CONO in every join, read column names as a two-char prefix plus a four-char alias (the prefixes quirk), and normalize quantities out of the item's basic unit of measure before aggregating a family.
Sourcing it from Oracle EBS
Table roles. The demand plan is two tables: MRP_FORECAST_DESIGNATORS names the forecast and the set it belongs to, and MRP_FORECAST_DATES is the fact under it — a row per item, org, forecast name, and bucket date, except that a row with RATE_END_DATE set spans every bucket through to that date and has to be expanded before it is summed. The supply response comes from ASCP plan output: MSC_PLANS is the plan registry and MSC_SUPPLIES the supply side of the run that plan produced. Actual demand is OE_ORDER_LINES_ALL.
The traps. The designator is the identity of a forecast, so it belongs in every join — and a row with a NULL forecast set is itself a set, which inflates a forecast count if it is not filtered. Forecast quantities are consumed: CURRENT_FORECAST_QUANTITY is post-consumption, so summing the original double-counts demand that sales orders already took. Plan output means nothing until one PLAN_ID is chosen, and the plan tables are overwritten by the next run, so the cycle snapshot is still a lakehouse capture taken at the lock date. Order lines carry operating-unit striping (MOAC in practice).
Sourcing it from Oracle Fusion
Table roles. The Fusion reference carries no plan table. Plans live in Supply Planning and reach the lakehouse through BICC extracts of their own, so the cycle snapshot is assembled downstream rather than read from a physical plan table. What this reference does carry is the demand actuals: DOO_FULFILL_LINES_ALL, at the fulfillment-line grain, rolled up to the family and month the review runs on.
The traps. Fusion Cloud has no direct SQL path; the physical names describe the source model and the extract must use the verified BICC/PVO surfaces available for those tables (no SQL path), and extract definitions drift with each release (PVO drift). That means the fact is assembled from two extract paths — the planning extract and the order-management extract — landed on different schedules, so the cycle key has to be stamped in the lakehouse rather than inherited. Keep business-unit scope explicit (_ALL means BU here) before a site cut is trusted.
Running the workshop
Three decisions have to be made before anyone builds — the first two are the grain of the fact, and the third decides what the demand number contains:
- Agree the family grain. S&OP is a family-level conversation, but "family" is a business decision, not a master-data attribute you can look up. Get the room to name the level it plans at and commit to it — too fine and the review drowns, too coarse and no one can act. Item-level detail belongs on the planner instrument, not here.
- Agree the lock calendar. Which day of the cycle the consensus plan is frozen, and who signs it. That date is when the snapshot gets captured, and a cycle nobody captured is a cycle nobody can ever score. Write it on a calendar before writing any SQL.
- Decide where the returns forecast enters the cycle. A returns forecast is not a demand forecast run backwards. Nothing returns that was not first shipped, so returns are conditional on prior issues rather than an independent series — and where returned units re-enter sellable supply, replenishment runs on net demand, demand minus returns. Placement is the decision, and there is nothing to inherit: full-text scans of the CSCMP glossary, the industry's main process-reference model and the ASCM Dictionary return nothing that seats a returns forecast in a named step of the monthly cycle. So the room has to author it: name the step returns enter at, and log the choice as an assumption the way the reference build's assumption log does.
And the anti-patterns to name out loud: scoring the latest plan (the plan gets edited toward actual all month, so the review congratulates itself), averaging attainment across families (a ratio of ratios that no grain agrees with), and a one-number review with no supply column — a demand number alone tells the room it missed, not whether the business could have delivered if it hadn't.