SoPoly Pricing Review v. 1.2
Window Trailing 60 Days

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 — so avg_cost (MRPeasy's own moving-average cost) is the best available stand-in.
  • Color/variant parameter (a relation_id placeholder, no fixed article). The most common case is lumber — a BOM calls for "whichever color this specific order uses" rather than one fixed board. 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 board: each color's own avg_cost is 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. The relation_id → base-size mapping (e.g. relation 15/16/30 → the 5/4"×5' board family) is a hand-verified table in costing.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) × the BOM's own recipe quantity

Direct Labor

Direct Labor comes straight from the Item's Routing template — each of its operations carries its own duration (fixed_time + variable_time) and its own blended cost (fixed_cost + variable_cost). Per explicit decision, that routing-level cost figure is used directly as Direct Labor, with no separate rate lookup:

Direct Labor = ∑ over Routing operations: fixed_cost + (variable_cost ÷ variable_quantity)

Known simplification, disclosed rather than hidden: fixed_cost (and fixed_time, used for Cycle Time below) are genuinely one-time-per-run figures — e.g. a machine setup cost that a real order would spread across every unit in its batch. Once Planned Cost stopped being tied to any specific MO, there's no order quantity left to amortize that fixed portion over, so this treats every SKU as if it were always run one unit at a time. That's a uniform treatment across every SKU (not a per-SKU data gap), and it skews Direct Labor and Cycle Time slightly high (conservative) for any SKU that's normally run in larger batches.

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)

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 Workstation Rates 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's parts[], priced at the specific real lot(s) actually drawn from.
  • Labor: the real labour_cost on every operation that has itself executed.
  • Applied Overhead: the real manufacturing_cost on 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 Workstation Rates 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_id that 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.

Rows with any unresolved input are marked with * next to the SKU code on the Pricing Review tab.