MRP — Material Requirements Planning (demand/supply netting → planned orders)
The whole MRP subsystem reduces to one idea: a
MrpRunis a planning batch; running it nets demand against supply per item, and every shortage (netRequirement > 0) becomes oneMrpResult"suggestion" row — either make it (CREATE_PRODUCTION_ORDER, if the item has an approved active BOM) or buy it (CREATE_PURCHASE_ORDER, otherwise). A second step, convert, turns an accepted suggestion into a real Production Order. Nothing here moves stock or posts to the GL; it only produces advice rows.
Source: BE src/modules/manufacturing/mrp · Admin src/modules/manufacturing/mrp
⚠️ Implementation status — read first. The planning engine (
MrpService.executeMrpRun/convertResult) is written but not exposed through any resolver, and its demand/supply math is stubbed (calculateGrossRequirementreturns a hard-coded100,calculateCurrentSupplyreturns50, and the item-fetch loop is commented out soitemsToProcessis always empty). What is wired end-to-end through GraphQL is CRUD overMrpRunandMrpResultplus themrpExceptionReportquery (documented in./accounting-reports.md). So today the admin can create/list/delete runs and list result rows, but the "press run → suggestions appear" loop is not callable. This doc describes both: the live CRUD surface and the (dormant) engine logic, flagging which is which. See §9.
1. Purpose & scope
MRP is the planning layer of the manufacturing domain. It answers "for each item, do we have enough to cover demand over the planning horizon, and if not, should we make it or buy it?". It sits above Production Orders (it can create them) and feeds off the BOM tree to explode finished-good demand into component demand.
Responsible for:
- Recording a planning batch (
MrpRun): horizon, item scope, status, and roll-up counters. - Netting gross requirement − current supply = net requirement per item.
- Emitting a suggestion row (
MrpResult) per shortage with an action (make / buy), suggested quantity, suggested date, and priority. - BOM explosion: a make-suggestion for a BOM item recurses through the BOM tree, emitting buy-suggestions for raw components (with phantom-BOM bubbling and scrap uplift).
- Conversion: turning a
CREATE_PRODUCTION_ORDERresult into an actual Production Order (source: "MRP").
Explicitly does NOT:
- Hold or move inventory (no
Stockrows — see inventory). - Post any GL legs (planning only).
- Create real Purchase Orders —
CREATE_PURCHASE_ORDERconversion is a stub integration point (createdOrderId = null). - Implement true time-phased buckets / lead-time offsetting — suggested dates are flat offsets (
+7dfor production,+14dfor purchase), not lead-time- or horizon-driven. - Reschedule / cancel / expedite — the
RESCHEDULE_IN/OUT,CANCEL,EXPEDITEaction enums exist but no code path ever produces them.
2. Data model
Two collections: mrp_runs (the batch header) and mrp_results (the suggestion rows). Both extend BaseSchema, use mongoose-delete (soft delete with deletedAt), and carry companyId / branchId tenant scoping.
2.1 mrp_runs — the planning batch (mrp/mrp-run/mrp-run.scheme.ts)
| field | type | required | description |
|---|---|---|---|
runNumber |
string |
yes | human key; resolver auto-fills MRP-<nanoId(8) upper> if omitted |
runDate |
number (unix ts) |
yes | stored via BaseSchema.toUnixTimestamp |
planningHorizonDays |
number |
no (default 30) |
intended planning window; not currently consumed by the engine |
status |
MrpRunStatus |
no (default PENDING) |
lifecycle (see §4 state machine) |
includeAllItems |
boolean |
no (default true) |
plan every item vs. a named subset |
itemIds |
ObjectId[] |
no | the subset to plan when includeAllItems = false |
totalPlannedProductionOrders |
number |
no (default 0) |
counter set at run completion |
totalPlannedPurchaseOrders |
number |
no (default 0) |
counter set at run completion |
totalExceptions |
number |
no (default 0) |
per-item error count during the run |
errorMessage |
string |
no | populated when the run FAILED |
completedAt |
number (unix ts) |
no | set when status → COMPLETED |
companyId, branchId |
ObjectId |
no | tenant/branch scope |
// mrp/mrp-run/mrp-run.scheme.ts
export enum MrpRunStatus {
PENDING = "PENDING", // created, not yet run (default)
RUNNING = "RUNNING", // engine in progress
COMPLETED = "COMPLETED", // finished OK
FAILED = "FAILED", // threw; errorMessage set
}2.2 mrp_results — the suggestion rows (mrp/mrp-result/mrp-result.scheme.ts)
One row per planned action for one item within one run.
| field | type | required | description |
|---|---|---|---|
mrpRunId |
ObjectId |
— | back-ref to the parent MrpRun |
itemId |
ObjectId |
— | the item this suggestion is for |
actionType |
MrpActionType |
yes | what to do (make / buy / …) |
suggestedQuantity |
number |
no (default 0) |
qty to make or buy (= net requirement, or exploded component qty) |
suggestedDate |
number (unix ts) |
no | flat offset: +7d (production) / +14d (purchase) |
currentStock |
number |
no (default 0) |
= supplyQuantity snapshot at run time |
demandQuantity |
number |
no (default 0) |
gross requirement |
supplyQuantity |
number |
no (default 0) |
current supply |
netRequirement |
number |
no (default 0) |
max(0, demand − supply) |
sourceDemand |
string |
no | free-text origin ("Sales Order / Safety Stock", "BOM Component Requirement", "Phantom BOM Explosion", "Raw Material Requirement") |
isConverted |
boolean |
no (default false) |
true once turned into a real order |
convertedOrderId |
ObjectId |
no | the Production Order created by conversion (null for purchase) |
priority |
MrpPriority |
no (default MEDIUM) |
always MEDIUM in current code |
notes |
string |
no | free text |
companyId, branchId |
ObjectId |
no | tenant/branch scope |
// mrp/mrp-result/mrp-result.scheme.ts
export enum MrpActionType {
CREATE_PRODUCTION_ORDER = "CREATE_PRODUCTION_ORDER", // make it (BOM item)
CREATE_PURCHASE_ORDER = "CREATE_PURCHASE_ORDER", // buy it (no BOM / raw component)
RESCHEDULE_IN = "RESCHEDULE_IN", // declared, never emitted
RESCHEDULE_OUT = "RESCHEDULE_OUT", // declared, never emitted
CANCEL = "CANCEL", // declared, never emitted
EXPEDITE = "EXPEDITE", // declared, never emitted
}
export enum MrpPriority { HIGH = "HIGH", MEDIUM = "MEDIUM", LOW = "LOW" }Relationships & joins. mrp_results aggregations $lookup the items collection ($lookupItem → item) and the parent run ($lookupMrpRun → mrpRun). MrpResult.findById/find/findOne/page all hydrate item + mrpRun. There is no virtual array of results on MrpRun; the admin detail page pulls results separately by mrpRunId filter.
3. API surface
All resolvers are @ApInitGqlAuthorize() + extend ApBaseResolver. Page queries use @ApGqlAuthorize({ includeBranchQuery: false }) (so a run is visible company-wide, not branch-filtered). Every mutation carries @AuditMeta({ module: "mrp", … }).
3.1 MRP Run (mrp/mrp-run/mrp-run.resolver.ts)
| Operation | Type | Input | Returns | Permission / audit |
|---|---|---|---|---|
mrpRunPage(page) |
Query | MrpRunPageInput {skip, take, keyword, sortBy, sortOrder, status} |
MrpRunPageResult {totalRecords, data} |
JWT + access group |
findMrpRun(_id) |
Query | ID |
MrpRun (nullable) |
JWT |
createMrpRun(mrpRun) |
Mutation | CreateMrpRunInput {runNumber?, runDate!, planningHorizonDays=30, includeAllItems=true, itemIds?} |
MrpRun |
JWT + audit(CREATE snapshot) |
updateMrpRun(_id, mrpRun) |
Mutation | UpdateMrpRunInput (partial + status, errorMessage, completedAt) |
MrpRun |
JWT + audit(UPDATE snapshot) |
deleteMrpRun(_id) |
Mutation | String |
Boolean |
JWT + audit(DELETE) |
deleteMrpRuns(_ids) |
Mutation | [String] |
Boolean |
JWT + audit(DELETE), loops delete per id |
createMrpRun defaults runNumber to MRP-${helper.nanoId(8).toUpperCase()} and stamps createdBy = user._id.
3.2 MRP Result (mrp/mrp-result/mrp-result.resolver.ts)
| Operation | Type | Input | Returns | Permission / audit |
|---|---|---|---|---|
mrpResultPage(page) |
Query | MrpResultPageInput {skip, take, keyword, sortBy, sortOrder, mrpRunId, actionType} |
MrpResultPageResult {totalRecords, data} |
JWT + access group |
findMrpResult(_id) |
Query | ID |
MrpResult (nullable) |
JWT |
createMrpResult(mrpResult) |
Mutation | CreateMrpResultInput |
MrpResult |
JWT + audit(CREATE) |
updateMrpResult(_id, mrpResult) |
Mutation | UpdateMrpResultInput (partial + isConverted, convertedOrderId) |
MrpResult |
JWT + audit(UPDATE) |
deleteMrpResult(_id) |
Mutation | String |
Boolean |
JWT + audit(DELETE) |
deleteMrpResults(_ids) |
Mutation | [String] |
Boolean |
JWT + audit(DELETE) |
There is no
executeMrpRun/convertMrpResultGraphQL operation.MrpService.executeMrpRun(runId, user)andMrpService.convertResult(resultId, user)exist inmrp/mrp.service.tsbut are not referenced by any resolver, controller, cron, or event handler (verified by grep). The only callers are the unit testmrp.service.spec.ts. To make MRP usable end-to-end you must add a resolver that calls these.
# schema.gql (verbatim shapes)
input CreateMrpRunInput {
runNumber: String
runDate: Float!
planningHorizonDays: Float = 30
includeAllItems: Boolean = true
itemIds: [String!]
}
input MrpResultPageInput {
skip: Float! take: Float! keyword: String
sortBy: String sortOrder: SortOrder
mrpRunId: String actionType: MrpActionType
}4. Business rules & calculations
4.1 The planning algorithm — MrpService.executeMrpRun(runId, user)
The intended (engine) flow, exactly as coded in mrp/mrp.service.ts:
1. run = mrpRunSvc.update(runId, { status: RUNNING })
2. itemsToProcess =
run.includeAllItems → (all items — fetch is COMMENTED OUT → [])
else run.itemIds?.len → (subset — fetch is COMMENTED OUT → [])
⇒ itemsToProcess is ALWAYS [] today.
3. for each item:
grossRequirement = calculateGrossRequirement(itemId) // STUB → 100
currentSupply = calculateCurrentSupply(itemId) // STUB → 50
netRequirement = max(0, grossRequirement − currentSupply)
if netRequirement > 0:
bom = bomSvc.findOne({ itemId, status: "ACTIVE", approvalStatus: "APPROVED" })
if bom: → MrpResult(actionType: CREATE_PRODUCTION_ORDER,
suggestedQuantity: netRequirement,
suggestedDate: now + 7d,
demandQuantity: gross, supplyQuantity/currentStock: supply,
netRequirement, sourceDemand: "Sales Order / Safety Stock",
priority: MEDIUM, isConverted: false)
totalPO++
exploseBom(bom._id, netRequirement, runId, user) // explode components
else: → MrpResult(actionType: CREATE_PURCHASE_ORDER,
suggestedDate: now + 14d,
sourceDemand: "Raw Material Requirement", …)
totalPurchaseO++
(per-item try/catch → on error: totalExceptions++, console.error, continue)
4. mrpRunSvc.update(runId, { status: COMPLETED,
totalPlannedProductionOrders: totalPO,
totalPlannedPurchaseOrders: totalPurchaseO,
totalExceptions, completedAt: now })
on any thrown error → update(runId, { status: FAILED, errorMessage }) then rethrow
Netting formula (the core of MRP):
netRequirement = max(0, grossRequirement − currentSupply)
grossRequirementis meant to beΣ open sales-order qty + safety stock. Currently stubbed to100(calculateGrossRequirementreturns a dummy).currentSupplyis meant to beon-hand stock netQuantity + open production orders + open purchase orders. Currently stubbed to50(calculateCurrentSupplyreturns a dummy).- A shortage (
net > 0) is split by make-vs-buy: an item with an active+approved BOM is made (production-order suggestion + BOM explosion); an item without one is bought (purchase-order suggestion).
4.2 BOM explosion — exploseBom(bomId, parentQuantity, mrpRunId, user) (recursive)
When a finished good is to be made, its BOM is exploded into component demand. The walk is recursive over the multi-level BOM tree:
bom = bomSvc.findById(bomId)
if bom.bomType == PHANTOM: // phantom: no PO for the phantom itself
for line in bomLineSvc.repo.find({ bomId }):
childQty = (line.quantity || 0) * parentQuantity
scrapMultiplier = 1 + (line.scrapPercent || 0) / 100
totalQty = childQty * scrapMultiplier
if line.componentBomId: exploseBom(line.componentBomId, totalQty, …) // recurse
else: MrpResult(CREATE_PURCHASE_ORDER, qty=totalQty,
suggestedDate: now+14d,
sourceDemand: "Phantom BOM Explosion")
return // skip normal handling for phantoms
# Standard BOM: identical loop, sourceDemand = "BOM Component Requirement"
for line in lines:
childQty = line.quantity * parentQuantity
totalQty = childQty * (1 + scrapPercent/100)
if line.componentBomId: exploseBom(line.componentBomId, totalQty, …) // sub-assembly recurse
else: MrpResult(CREATE_PURCHASE_ORDER, qty=totalQty, …)
Component requirement math (per BOM line):
requiredQty = (line.quantity × parentQuantity) × (1 + line.scrapPercent / 100)
i.e. per-unit usage × how many parents we're making, then uplifted for expected scrap. (line.quantity, line.scrapPercent, line.componentItemId, line.componentBomId come from manufacturing_bom_lines — see BOM line schema bom/line/line.scheme.ts.)
- Phantom BOMs (
BomType.PHANTOM): no suggestion is created for the phantom assembly itself; its components "bubble up" to the parent's plan. This matches standard MRP phantom behavior. - Sub-assemblies (
componentBomIdset): the engine recurses, so multi-level BOMs explode all the way to purchased raw materials. - Leaf components (
componentItemId, nocomponentBomId): emit aCREATE_PURCHASE_ORDERresult. - The whole
exploseBombody is wrapped in try/catch — an explosion error is logged and swallowed (does not fail the run).
The explosion always emits
CREATE_PURCHASE_ORDERfor leaf components — it does not re-net component demand against component on-hand/supply, and it does not check whether a leaf component itself has its own BOM beyond the explicitcomponentBomIdlink. There is no lot-sizing, lead-time offsetting, or demand aggregation across lines/items.
4.3 Conversion — MrpService.convertResult(resultId, user)
Turns an accepted suggestion into a real order:
result = mrpResultSvc.findById(resultId)
if !result → throw "MRP Result not found"
if result.isConverted → throw "MRP Result already converted"
if actionType == CREATE_PRODUCTION_ORDER:
po = productionOrderSvc.create({
itemId, quantityPlanned: suggestedQuantity,
startDate: now, dueDate: suggestedDate,
source: "MRP", notes: `Created from MRP Run: ${mrpRunId}`,
createdBy, companyId, branchId })
createdOrderId = po._id
elif actionType == CREATE_PURCHASE_ORDER:
createdOrderId = null // ← STUB: purchase-order integration not implemented
mrpResultSvc.update(resultId, { isConverted: true,
convertedOrderId: createdOrderId ?? null })
- Production-order conversion is real: it calls
ProductionOrderService.createwithsource: "MRP"and links the new PO back viaconvertedOrderId. - Purchase-order conversion is a no-op flag flip — it marks
isConverted: truebut creates nothing (convertedOrderIdstays null). Integrating real Purchase Orders is a TODO at that call-site. - Conversion is idempotent-guarded: a second convert throws
"MRP Result already converted".
4.4 Status / state machine (MrpRun)
createMrpRun executeMrpRun (engine) executeMrpRun (engine)
(none) ──────────────▶ PENDING ──────────────────────────▶ RUNNING ──────────────────────────▶ COMPLETED
(status := RUNNING) (status := COMPLETED,
counters + completedAt set)
└──(throws)──▶ FAILED (errorMessage set)
- The transitions to
RUNNING → COMPLETED/FAILEDare driven only byexecuteMrpRun. Since that method is not exposed, in the live system runs stayPENDINGunlessupdateMrpRun(status: …)is called manually. updateMrpRuncan setstatusdirectly (theUpdateMrpRunInputexposes it), so the state machine is not enforced server-side — any status can be written.
4.5 Side effects & transactionality
- No GL legs, no stock rows. MRP is pure planning; the only writes are
mrp_resultsrows and the run counters. - Production order creation on conversion is the one cross-module write (into
manufacturing_production_ordersviaProductionOrderService). - Audit trail fires on every CRUD mutation via
@AuditMeta. - Transactionality:
executeMrpRunandconvertResultare not wrapped in a Mongo session/transaction — eachmrpResultSvc.create/updatecommits independently. (Contrast inventory flows, which usewithRetryTransaction.) A mid-run failure can leave partial result rows plus aFAILEDrun.MrpRunService/MrpResultServiceextendAbstractBaseServicebut theirsetSessionis a no-op.
5. Permissions
- All resolvers:
@ApInitGqlAuthorize()(JWT required). Page queries additionally apply@ApGqlAuthorize({ includeBranchQuery: false })— access-group gated but not branch-scoped, so MRP runs/results are visible across branches within a company. - Audit module tag:
"mrp", collectionsmrp_runs/mrp_results, withCREATE/UPDATE/DELETEsnapshots. - No CASL ability strings or custom role gates beyond the standard access-group check. See permissions and audit trail.
6. Flows
6.1 Create + run MRP (intended end-to-end, engine dormant)
- Admin → Manufacturing › MRP → "New MRP Run" →
CreateMrpRunform (run number optional, run date, horizon, include-all). createMrpRunmutation →MrpRunService.create→mrp_runsrow,status: PENDING,runNumberauto-generated if blank,createdBystamped.- (intended) A "Run" action →
MrpService.executeMrpRun(runId, user): a. status →RUNNING. b. for each in-scope item: netgross − supply; if short, emit a make/buyMrpResult; if make,exploseBomfor components. c. status →COMPLETED, counters +completedAtset. - Admin opens the run detail →
mrpResultPage(mrpRunId)→ table of suggestions. - (intended) Admin accepts a production suggestion →
MrpService.convertResult→ real Production Order, result flaggedisConverted.
Unhappy paths:
- Engine throws mid-run → run set to
FAILEDwitherrorMessage; error rethrown. - Per-item error →
totalExceptions++, logged, loop continues (run still completes). - Convert an already-converted result → throws
"MRP Result already converted". - Convert a missing result → throws
"MRP Result not found".
6.2 What actually works today (live CRUD)
- Create run (
createMrpRun) →PENDING. - List runs (
mrpRunPage), open detail (findMrpRunviafindMrpRunAsync), delete (deleteMrpRun). - List result rows (
mrpResultPagefiltered bymrpRunId) — but no rows exist unless seeded/created manually viacreateMrpResult, because the engine that populates them isn't callable.
7. Admin UI
Source: zerp-admin/src/modules/manufacturing/mrp — follows the zync-nextjs context pattern (useMrpState() → useMrpQuery() → Apollo). Page route: /manufacturing/mrp; detail rendered by MrpDetailPage (detail.tsx).
page.tsx(MrpPage):ApPageHeader "Material Requirements Planning (MRP)"+ "New MRP Run" button →ApModalwithCreateMrpRun;ApSearchInput(keyword);MrpRunTable. Refetches onfilterchange.detail.tsx(MrpDetailPage): run info grid (run number, date, statusTag, horizon, planned-PO/PO counters, exceptions, completedAt, error) +MrpResultTableof suggestions (fetched bymrpRunId).components/create.tsx(CreateMrpRun): Formik + Yup (runDaterequired). Fields:runNumber(ApIdInput, localStoragemfg_run_number),runDate(ApDateInput),planningHorizonDays(number, default 30). Submits{ runNumber?, runDate, planningHorizonDays, includeAllItems }. Note the form setsincludeAllItemsbut exposes no UI toggle/itemIdspicker — it defaults totrue.components/result-table.tsx(MrpResultTable): AntApTablecolumns — Item ID, Action (Tag, underscores → spaces), Priority (colorTag), Suggested Qty/Date, Current Stock, Demand, Supply, Net Req, Converted (TagYes/No).context.tsx(useMrpState): exposesfetchMrpRuns,createMrpRun,deleteMrpRun,fetchMrpResults, plus filter/modal state. Optimistic local list updates (setMrpRuns([data, ...])on create, filter-out on delete).
Context gaps (live UI ↔︎ engine mismatch): the admin context wires no "execute run" or "convert result" call — consistent with the BE engine not being exposed. There is also no
updateMrpRunin the GQL hook (gql/query.tsonly defines run page/find/create/delete + result page; no result create/update/delete, no allocate-style workflow). So the UI is create/list/delete + read-only results. Surfacing run/convert requires (a) BE resolvers forexecuteMrpRun/convertResult, then (b) extendinguseMrpQuery/context.tsxto call them andreload().
8. Dependencies & integrations
- BOM module (
../bom):BomService.findOne(find the item's active+approved BOM),BomService.findById,BomLineService.repo.find(component lines). Drives make-vs-buy and explosion.BomType.PHANTOMandBomLine.scrapPercent/componentBomIdare the explosion inputs. - Production module (
../production):ProductionOrderService.createon conversion (source: "MRP"). - Inventory / Sales (intended):
calculateCurrentSupplyshould read inventory stock + open POs;calculateGrossRequirementshould read open sales orders + safety stock. Both are stubs today — these integration points are unimplemented. - Purchasing (intended):
CREATE_PURCHASE_ORDERconversion should call a purchase-order service — currently a no-op stub. - Module wiring (
mrp.module.ts): importsMrpRunModule,MrpResultModule,ProductionOrderModule,BomModule(allforwardRef); provides+exportsMrpService. No cron, no event subscribers, no external services.
9. Gotchas & project-specific rules
- The engine is not callable.
executeMrpRun/convertResulthave no resolver/cron/event entry point. Live MRP = CRUD only. This is the single most important fact for a rebuild. - Demand/supply math is stubbed.
calculateGrossRequirement → 100,calculateCurrentSupply → 50, anditemsToProcessis always[](item fetch commented out). Even if you wired a resolver toexecuteMrpRun, it would produce zero results until you implement those three. - Four action enums are dead. Only
CREATE_PRODUCTION_ORDERandCREATE_PURCHASE_ORDERare ever emitted;RESCHEDULE_IN/OUT,CANCEL,EXPEDITEexist for future use. - Priority is always MEDIUM, suggested dates are flat offsets (
+7dmake /+14dbuy) — no lead-time or horizon math.planningHorizonDaysis stored but never read by the engine. - No transaction. Engine result writes are not atomic; a mid-run throw leaves partial rows + a
FAILEDrun. - Status is not enforced.
updateMrpRuncan set anystatusdirectly; the PENDING→RUNNING→COMPLETED machine is convention, not a guard. - Page queries are company-wide (
includeBranchQuery: false) — runs/results are not branch-filtered even though both carrybranchId. - Purchase-order conversion is a flag flip (
convertedOrderIdstays null); only production orders are actually created. - Component explosion does not re-net. Exploded components always become buy-suggestions at
requiredQty, ignoring component on-hand/open supply — a single-level net at the top, then a gross blow-down below.
Cross-links
- BOM tree, lines, scrap %, phantom type:
bom/bom.scheme.ts,bom/line/line.scheme.ts(manufacturing BOM module) - Production orders created on conversion:
../manufacturing/production module (production/production.scheme.ts,source: MRP) - Costing / WIP / variances posted by production (not MRP):
./accounting-reports.md - Quality inspection on produced output:
./quality.md - Inventory stock the supply calc should read:
../inventory/stock.md - Permissions / audit:
../../platform/permissions-access.md,../../platform/audit-trail.md