Cost Methodology
How MRP Actual Cost and MRP Planned Cost are each computed · last rebuilt 2026-07-20
The two numbers answer different questions
MRP Actual Cost asks "what did this SKU's real orders actually cost?" It is built entirely from mo-history.json — each specific Manufacturing Order's own real parts consumed and operations executed — averaged across every in-window MO pooled under that SKU. It only exists once an order has run.
MRP Planned Cost asks "what should this SKU cost, by its own designed recipe?" As of 2026-07-20 it no longer looks at any specific Manufacturing Order at all. It is computed once per SKU, straight from that Item's own Bill of Materials (BOM) and Routing template — the same number for every MO pooled under that row, whether that MO ran yesterday or hasn't been scheduled yet.
This is a deliberate split, not an oversight: Actual tells you what happened; Planned tells you what the recipe says should happen, independent of how any particular run actually went.
Planned Cost: Item → SKU → BOM → Routing
Every SKU on the Pricing Review tab is a parent product_id pooling one or more real Item variants (colors/sizes/finishes — e.g. SAVC1-AU-AU-AS, SAVC1-BL-BL-KN, etc. all under parent SKU SAVC1). Planned Cost resolves that same product_id to exactly one BOM (staticCache.boms[], matched on bom.product_id), then to exactly one Routing (the first of that BOM's own routings[] entries that resolves). If a product_id has more than one real BOM on file (13 of 734 product_ids, in every case checked pointing at the identical routing_id either way), the higher bom_id — the newer revision — is used, since a forward-looking Planned Cost should reflect the current recipe.
If no BOM resolves for a SKU at all, Planned Cost is N/A for that row (flagged planned-sku-missing-bom) rather than guessed at.
Materials
Every BOM component falls into one of two kinds:
- Fixed article (a specific real part). Priced at that item's own
avg_cost, at the BOM's own recipe quantity. There is no per-MO real lot to read a price from here — Planned Cost no longer looks at any specific MO — soavg_cost(MRPeasy's own moving-average cost) is the best available stand-in. - Color/variant parameter (a
relation_idplaceholder, no fixed article). The most common case is lumber — a BOM calls for "whichever color this specific order uses" rather than one fixed board. For cushion fabric and baluster colors, this app remembers the BOM's own recipe quantity for that placeholder, then prices it using a usage-weighted average across every real color/size of that same base item: each color's ownavg_costis weighted by how much of that color has actually been consumed/booked historically (all-time), so a color ordered constantly pulls the average toward its own price and a color ordered once barely moves it. Lumber uses this same usage-weightedavg_costaverage only as a fallback — see the Lumber Extrusion Data Formula block below for its own, preferred pricing path. The relation_id → base-size mapping (e.g. relation 15/16/30 → the 5/4"×5' board family) is a hand-verified table incosting.js, covering lumber, cushion fabric, and baluster color families. A relation_id with no such mapping (e.g. propane vs. natural gas fire-pit burners — genuinely different equipment, not interchangeable colors) is left unresolved and flagged rather than averaged into a misleading number.
Materials = ∑ over BOM components: (fixed article → avg_cost, or color placeholder → usage-weighted average or Extrusion Data formula) × the BOM's own recipe quantity
Lumber Extrusion Data Formula (preferred over avg_cost for lumber)
Added 2026-08-18: MRPeasy's own avg_cost for a lumber color was root-caused as untrustworthy — confirmed against a real Item BOM (BO00385, W075060DE) that one color's own raw-material-only sub-BOM cost ($8.25) can sit above the usage-weighted average of avg_cost across every color of that size, meaning the averaging was papering over a real cost avg_cost doesn't honestly reflect. Lumber now prices from this app's own formula instead, computed once per profile (a board size, e.g. W075060) rather than per color — every color of the same size gets the identical figure.
Blended Resin $/lb = Virgin % × Virgin Resin $/lb + Regrind % × Regrind Resin $/lb
Materials (per board) = Weight × [(1 − Colorant % − Foam %) × Blended Resin $/lb] + Weight × Colorant % × Colorant $/lb + Weight × Foam % × Foam $/lb
Colorant % and Foam % are entered as this app's own assumptions on the Planned Costs tab's Extrusion Data section — per explicit instruction, MRPeasy's own per-color Colorant/Foam BOM quantities are disregarded in favor of these two rates, applied uniformly to every color of a given size. Virgin/Regrind % is likewise entered there, since MRPeasy's own BOM never splits Resin into virgin vs. regrind at all — it's one combined quantity.
Direct Labor and Overhead for lumber don't reuse the board's own Routing duration — instead, a Cycle Time is derived from that profile's own real Extrusion line Speed (FPM, also entered on the Planned Costs tab) and its board length (derived from the base code itself, e.g. W075060 → 5', not separately entered):
Cycle Time = Board Length (ft) ÷ Speed (FPM)
That Cycle Time then feeds the exact same two formulas as every other Planned Cost operation: Direct Labor = Cycle Time × the DL Planned Rate above; Overhead = Cycle Time × the Extrusion work center's own Hourly Rate (from the Workstation Hourly Rates table on the same tab — "Overhead has its own Workstation Rate," so no separate field was added for it).
All 7 Ingredient Rates fields (4 $/lb prices, Virgin %, Colorant %, Foam %) plus a specific profile's own Weight and Speed must be filled in before this formula is used for that profile — a half-filled-in rate would silently understate/overstate every lumber SKU, so it's all-or-nothing rather than a partial blend. Any profile still missing a piece of its setup falls back to the original usage-weighted avg_cost average, flagged so it's visible which SKUs would benefit from finishing the setup.
Direct Labor
Direct Labor is Cycle Time (below) multiplied by a single flat, editable DL Planned Rate ($/hr, same rate for every work center type):
Direct Labor = ∑ over Routing operations: Cycle Time × DL Planned Rate
Superseded 2026-08-17. Direct Labor previously read each operation's own fixed_cost + variable_cost ÷ variable_quantity straight off the Routing template, with no rate lookup at all. That was root-caused as unreliable: checked against MRPeasy's own Routing detail screen (R00005, CALBE5 Callaway Bench), every operation's Fixed cost was $0.00 and Variable cost was a flat, manually-typed number with no visible derivation — e.g. a 75-minute operation costing roughly 5× a 56-minute operation, even though both work centers pay within a couple dollars an hour of each other. Re-keying Fixed/Variable cost by hand across 700+ routings in MRPeasy wasn't practical, so Direct Labor now shares Cycle Time's already-trustworthy duration (the same figure that correctly drives Overhead) against one flat company rate instead, editable on the Planned Costs tab's pinned "DL Planned Rate" row.
Cycle Time & Overhead
Cycle Time is the same Routing operation's own duration, at the same per-unit (batch-of-one) basis as Direct Labor above:
Cycle Time = ∑ over Routing operations: fixed_time + (variable_time ÷ variable_quantity)
Direct Labor's DL Planned Rate follows the same live-blob-with-fallback pattern as Overhead's Hourly Rate below: the Planned Costs tab's workstation-rates.json (dl_planned_rate field) takes priority, falling back to costing.js's DL_PLANNED_RATE_DEFAULT (currently $20.00/hr) until a value is saved.
Overhead is Cycle Time multiplied by that operation's workstation's own real Hourly Rate:
Overhead = ∑ over Routing operations: Cycle Time × Hourly Rate (per workstation type)
This is a real rate, not an estimate, and it's editable without a code change. MRPeasy's own public REST API confirmed to genuinely never return this field — it's write-only there (WorkStationInput/WorkStationUpdateInput accept an hourly_rate on create/update; the read schema, WorkStation, omits it, and a real authenticated GET /work-centers call confirmed this in practice, not just in the docs). The real rate was instead found in MRPeasy's own Workstations settings page (Production Planning → Workstations) — its "Type" filter dropdown embeds each work center type's configured hourly_rate directly in the page's own HTML/JS.
The rate is technically configurable per physical work center (not per type), but every real work center of the same type currently shares the identical configured rate (e.g. all 4 "Hand Routers" machines are $30.09/hr) — so a type-keyed rate loses no real precision today, and conveniently matches the granularity a Routing can express in the first place (a routing names a work center type/group, never one specific physical machine — a specific machine is only assigned once an MO is actually scheduled).
Three-tier lookup, most-authoritative first: (1) the Planned Costs tab's own workstation-rates.json — a real Azure Blob, editable there directly (type a number, or paste JSON pulled straight off MRPeasy's settings page via a small console script the tab itself documents) with no code change or redeploy needed; (2) costing.js's hand-transcribed WORKSTATION_HOURLY_RATE_BY_TYPE_ID table — what bootstrapped that blob's very first save, and the fallback if the blob is ever missing a type_id; verified to cover all 20 real work center types in sopoly-static-cache.json, a clean 1:1 match, not a partial list; (3) this app's older historical-actuals-derived $/second rate, built from real executed operations in mo-history.json (excluding a known MRPeasy overhead-calculation breakage window, Jan–Jul 2026 — see BUILD_CONTEXT.md), only if neither of the first two has a value for this type_id.
A work center type missing from BOTH the live blob and the hand-transcribed table (not expected today - every real type is covered by the second tier alone) falls back to tier 3. Only that fallback case carries the "interim rate" pill on the Pricing Review tab.
Putting it together
Planned Cost (per SKU) = Materials + Direct Labor + Overhead
This is a single number per SKU, computed once at the row level on the Pricing Review tab (expand a row's cost breakdown to see every component and operation line behind it). Per explicit request, it's also shown on every individual MO in that row's "See MOs" list — deliberately the same, consistent figure on each one (it has no per-MO input to vary by), so Actual Cost and Planned Cost are easy to compare MO-by-MO against one stable number.
MRP Actual Cost, for contrast (unchanged by the above)
Actual Cost is untouched by this redesign — it still reads each specific MO's own real, executed history:
- Materials: the real quantity consumed/booked from
mo-history.json'sparts[], priced at the specific real lot(s) actually drawn from. - Labor: the real
labour_coston every operation that has itself executed. - Applied Overhead: the real
manufacturing_coston every operation that has itself executed — MRPeasy's own settled figure, not an estimate.
Actual Cost is averaged across every real, in-window MO pooled under a SKU, and stays blank for an order until its own MO reaches Done (or, per explicit instruction, shows a real partial total as operations complete on an in-progress order).
Known caveats, all disclosed rather than hidden
- Overhead's Hourly Rate is editable on the Planned Costs tab, but nothing pulls it from MRPeasy automatically - a real rate change there still needs someone to re-check the settings page and update it here (by hand, or by pasting the console-script JSON that tab documents). A work center type missing from both the live blob and the hand-transcribed fallback (none today) falls back to an older, historically-derived rate instead.
- Direct Labor and Cycle Time assume a batch size of one, overstating the fixed-cost/fixed-time portion for any SKU normally run in larger batches.
- A BOM component with a
relation_idthat has no hand-built base-size mapping (e.g. fire-pit burner fuel type) is left unpriced and flagged, not guessed at. - 13 of 734 product_ids have two real BOM revisions on file; the newer (higher
bom_id) one is used. - A SKU with no resolvable BOM at all shows Planned Cost as N/A rather than a fallback estimate.
- The lumber Extrusion Data formula's Colorant %/Foam % and per-profile Weight are this app's own entered assumptions, not derived from any Item's real BOM — they need periodic re-checking against real production data the same way any other manually-maintained rate does.
- Under the Extrusion Data formula, every color of the same lumber size shares the identical Planned Cost figure (Colorant/Foam cost no longer varies by color) — a real behavior change from the old usage-weighted
avg_costaverage, which did vary color by color.
Rows with any unresolved input are marked with * next to the SKU code on the Pricing Review tab.
