MRP — Material Requirements Planning (demand/supply netting → planned orders)

The whole MRP subsystem reduces to one idea: a MrpRun is a planning batch; running it nets demand against supply per item, and every shortage (netRequirement > 0) becomes one MrpResult "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 (calculateGrossRequirement returns a hard-coded 100, calculateCurrentSupply returns 50, and the item-fetch loop is commented out so itemsToProcess is always empty). What is wired end-to-end through GraphQL is CRUD over MrpRun and MrpResult plus the mrpExceptionReport query (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_ORDER result into an actual Production Order (source: "MRP").

Explicitly does NOT:

  • Hold or move inventory (no Stock rows — see inventory).
  • Post any GL legs (planning only).
  • Create real Purchase Orders — CREATE_PURCHASE_ORDER conversion is a stub integration point (createdOrderId = null).
  • Implement true time-phased buckets / lead-time offsetting — suggested dates are flat offsets (+7d for production, +14d for purchase), not lead-time- or horizon-driven.
  • Reschedule / cancel / expedite — the RESCHEDULE_IN/OUT, CANCEL, EXPEDITE action 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 ($lookupItemitem) and the parent run ($lookupMrpRunmrpRun). 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 / convertMrpResult GraphQL operation. MrpService.executeMrpRun(runId, user) and MrpService.convertResult(resultId, user) exist in mrp/mrp.service.ts but are not referenced by any resolver, controller, cron, or event handler (verified by grep). The only callers are the unit test mrp.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)
  • grossRequirement is meant to be Σ open sales-order qty + safety stock. Currently stubbed to 100 (calculateGrossRequirement returns a dummy).
  • currentSupply is meant to be on-hand stock netQuantity + open production orders + open purchase orders. Currently stubbed to 50 (calculateCurrentSupply returns 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 (componentBomId set): the engine recurses, so multi-level BOMs explode all the way to purchased raw materials.
  • Leaf components (componentItemId, no componentBomId): emit a CREATE_PURCHASE_ORDER result.
  • The whole exploseBom body is wrapped in try/catch — an explosion error is logged and swallowed (does not fail the run).

The explosion always emits CREATE_PURCHASE_ORDER for 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 explicit componentBomId link. 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.create with source: "MRP" and links the new PO back via convertedOrderId.
  • Purchase-order conversion is a no-op flag flip — it marks isConverted: true but creates nothing (convertedOrderId stays 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/FAILED are driven only by executeMrpRun. Since that method is not exposed, in the live system runs stay PENDING unless updateMrpRun(status: …) is called manually.
  • updateMrpRun can set status directly (the UpdateMrpRunInput exposes 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_results rows and the run counters.
  • Production order creation on conversion is the one cross-module write (into manufacturing_production_orders via ProductionOrderService).
  • Audit trail fires on every CRUD mutation via @AuditMeta.
  • Transactionality: executeMrpRun and convertResult are not wrapped in a Mongo session/transaction — each mrpResultSvc.create / update commits independently. (Contrast inventory flows, which use withRetryTransaction.) A mid-run failure can leave partial result rows plus a FAILED run. MrpRunService / MrpResultService extend AbstractBaseService but their setSession is 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", collections mrp_runs / mrp_results, with CREATE/UPDATE/DELETE snapshots.
  • 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)

  1. Admin → Manufacturing › MRP → "New MRP Run" → CreateMrpRun form (run number optional, run date, horizon, include-all).
  2. createMrpRun mutation → MrpRunService.createmrp_runs row, status: PENDING, runNumber auto-generated if blank, createdBy stamped.
  3. (intended) A "Run" action → MrpService.executeMrpRun(runId, user): a. status → RUNNING. b. for each in-scope item: net gross − supply; if short, emit a make/buy MrpResult; if make, exploseBom for components. c. status → COMPLETED, counters + completedAt set.
  4. Admin opens the run detail → mrpResultPage(mrpRunId) → table of suggestions.
  5. (intended) Admin accepts a production suggestion → MrpService.convertResult → real Production Order, result flagged isConverted.

Unhappy paths:

  • Engine throws mid-run → run set to FAILED with errorMessage; 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)

  1. Create run (createMrpRun) → PENDING.
  2. List runs (mrpRunPage), open detail (findMrpRun via findMrpRunAsync), delete (deleteMrpRun).
  3. List result rows (mrpResultPage filtered by mrpRunId) — but no rows exist unless seeded/created manually via createMrpResult, 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 → ApModal with CreateMrpRun; ApSearchInput (keyword); MrpRunTable. Refetches on filter change.
  • detail.tsx (MrpDetailPage): run info grid (run number, date, status Tag, horizon, planned-PO/PO counters, exceptions, completedAt, error) + MrpResultTable of suggestions (fetched by mrpRunId).
  • components/create.tsx (CreateMrpRun): Formik + Yup (runDate required). Fields: runNumber (ApIdInput, localStorage mfg_run_number), runDate (ApDateInput), planningHorizonDays (number, default 30). Submits { runNumber?, runDate, planningHorizonDays, includeAllItems }. Note the form sets includeAllItems but exposes no UI toggle/itemIds picker — it defaults to true.
  • components/result-table.tsx (MrpResultTable): Ant ApTable columns — Item ID, Action (Tag, underscores → spaces), Priority (color Tag), Suggested Qty/Date, Current Stock, Demand, Supply, Net Req, Converted (Tag Yes/No).
  • context.tsx (useMrpState): exposes fetchMrpRuns, 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 updateMrpRun in the GQL hook (gql/query.ts only 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 for executeMrpRun/convertResult, then (b) extending useMrpQuery/context.tsx to call them and reload().


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.PHANTOM and BomLine.scrapPercent / componentBomId are the explosion inputs.
  • Production module (../production): ProductionOrderService.create on conversion (source: "MRP").
  • Inventory / Sales (intended): calculateCurrentSupply should read inventory stock + open POs; calculateGrossRequirement should read open sales orders + safety stock. Both are stubs today — these integration points are unimplemented.
  • Purchasing (intended): CREATE_PURCHASE_ORDER conversion should call a purchase-order service — currently a no-op stub.
  • Module wiring (mrp.module.ts): imports MrpRunModule, MrpResultModule, ProductionOrderModule, BomModule (all forwardRef); provides+exports MrpService. No cron, no event subscribers, no external services.

9. Gotchas & project-specific rules

  • The engine is not callable. executeMrpRun / convertResult have 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, and itemsToProcess is always [] (item fetch commented out). Even if you wired a resolver to executeMrpRun, it would produce zero results until you implement those three.
  • Four action enums are dead. Only CREATE_PRODUCTION_ORDER and CREATE_PURCHASE_ORDER are ever emitted; RESCHEDULE_IN/OUT, CANCEL, EXPEDITE exist for future use.
  • Priority is always MEDIUM, suggested dates are flat offsets (+7d make / +14d buy) — no lead-time or horizon math. planningHorizonDays is stored but never read by the engine.
  • No transaction. Engine result writes are not atomic; a mid-run throw leaves partial rows + a FAILED run.
  • Status is not enforced. updateMrpRun can set any status directly; 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 carry branchId.
  • Purchase-order conversion is a flag flip (convertedOrderId stays 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.